From bc70619b17e877125887c12a75973e620640d41c Mon Sep 17 00:00:00 2001 From: nielstron Date: Thu, 22 Mar 2018 14:37:18 +0100 Subject: [PATCH 001/147] Added bandpass filter Allows values in a given range --- homeassistant/components/sensor/filter.py | 62 ++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 9c05028b3944ff..79a91c18cd433d 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -11,6 +11,7 @@ from functools import partial from copy import copy from datetime import timedelta +import math import voluptuous as vol @@ -28,6 +29,7 @@ _LOGGER = logging.getLogger(__name__) +FILTER_NAME_BANDPASS = 'bandpass' FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' FILTER_NAME_THROTTLE = 'throttle' @@ -40,6 +42,8 @@ CONF_FILTER_PRECISION = 'precision' CONF_FILTER_RADIUS = 'radius' CONF_FILTER_TIME_CONSTANT = 'time_constant' +CONF_FILTER_LOWER_BOUND = 'lower_bound' +CONF_FILTER_UPPER_BOUND = 'upper_bound' CONF_TIME_SMA_TYPE = 'type' TIME_SMA_LAST = 'last' @@ -51,6 +55,8 @@ DEFAULT_PRECISION = 2 DEFAULT_FILTER_RADIUS = 2.0 DEFAULT_FILTER_TIME_CONSTANT = 10 +DEFAULT_LOWER_BOUND = -math.inf +DEFAULT_UPPER_BOUND = math.inf NAME_TEMPLATE = "{} filter" ICON = 'mdi:chart-line-variant' @@ -77,6 +83,14 @@ default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), }) +FILTER_BANDPASS_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_BANDPASS, + vol.Optional(CONF_FILTER_LOWER_BOUND, + default=DEFAULT_LOWER_BOUND): vol.Coerce(float), + vol.Optional(CONF_FILTER_UPPER_BOUND, + default=DEFAULT_UPPER_BOUND): vol.Coerce(float), +}) + FILTER_TIME_SMA_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_SMA, vol.Optional(CONF_TIME_SMA_TYPE, @@ -100,7 +114,8 @@ [vol.Any(FILTER_OUTLIER_SCHEMA, FILTER_LOWPASS_SCHEMA, FILTER_TIME_SMA_SCHEMA, - FILTER_THROTTLE_SCHEMA)]) + FILTER_THROTTLE_SCHEMA, + FILTER_BANDPASS_SCHEMA)]) }) @@ -325,6 +340,51 @@ def filter_state(self, new_state): return new_state +@FILTERS.register(FILTER_NAME_BANDPASS) +class BandPassFilter(Filter): + """Band pass filter. + + Determines if new state is in a band between upper_bound and lower_bound. + If not inside, lower or upper bound is returned instead. + + Args: + upper_bound (float): band upper bound + lower_bound (float): band lower bound + """ + + def __init__(self, window_size=1, precision=None, entity, + lower_bound=math.inf, upper_bound=-math.inf): + """Initialize Filter.""" + super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + self._lower_bound = lower_bound + self._upper_bound = upper_bound + self._stats_internal = Counter() + + def _filter_state(self, new_state): + """Implement the outlier filter.""" + new_state = float(new_state) + + if new_state > self._upper_bound: + + self._stats_internal['erasures_up'] += 1 + + _LOGGER.debug("Upper outlier nr. %s in %s: %s", + self._stats_internal['erasures_up'], + self._entity, new_state) + return self._upper_bound + + if new_state < self._lower_bound: + + self._stats_internal['erasures_low'] += 1 + + _LOGGER.debug("Lower outlier nr. %s in %s: %s", + self._stats_internal['erasures_low'], + self._entity, new_state) + return self._lower_bound + + return new_state + + @FILTERS.register(FILTER_NAME_OUTLIER) class OutlierFilter(Filter): """BASIC outlier filter. From 3faed2edc14463e1257c55149d8826d600d017f3 Mon Sep 17 00:00:00 2001 From: nielstron Date: Thu, 22 Mar 2018 14:37:39 +0100 Subject: [PATCH 002/147] Add test for new band_pass filter --- tests/components/sensor/test_filter.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 8e79306fe136a2..09b56ed6be4a0d 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -131,6 +131,22 @@ def test_lowpass(self): filtered = filt.filter_state(state) self.assertEqual(18.05, filtered.state) + def test_bandpass(self): + """Test if bandpass filter works.""" + lower = 10 + upper = 20 + filt = LowPassFilter(entity=None, + lower_bound=lower, + upper_bound=upper) + for state in self.values: + filtered = filt.filter_state(state) + if state < lower: + self.assertEqual(lower, filtered) + elif state > upper: + self.assertEqual(upper, filtered) + else: + self.assertEqual(state, filtered) + def test_throttle(self): """Test if lowpass filter works.""" filt = ThrottleFilter(window_size=3, From 850131229220275b6b2a45dff0cd990b868fc103 Mon Sep 17 00:00:00 2001 From: nielstron Date: Thu, 22 Mar 2018 14:46:51 +0100 Subject: [PATCH 003/147] Reordered attribute order --- homeassistant/components/sensor/filter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 79a91c18cd433d..4505a794883418 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -85,6 +85,8 @@ FILTER_BANDPASS_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_BANDPASS, + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_LOWER_BOUND, default=DEFAULT_LOWER_BOUND): vol.Coerce(float), vol.Optional(CONF_FILTER_UPPER_BOUND, @@ -352,7 +354,7 @@ class BandPassFilter(Filter): lower_bound (float): band lower bound """ - def __init__(self, window_size=1, precision=None, entity, + def __init__(self, window_size, precision, entity, lower_bound=math.inf, upper_bound=-math.inf): """Initialize Filter.""" super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) From b42f4012d1df3ab2ecf8963772b98e0b0e8a1bb6 Mon Sep 17 00:00:00 2001 From: nielstron Date: Thu, 22 Mar 2018 15:29:56 +0100 Subject: [PATCH 004/147] Fixed test --- tests/components/sensor/test_filter.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 09b56ed6be4a0d..e43df67c84f13c 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -4,7 +4,8 @@ from unittest.mock import patch from homeassistant.components.sensor.filter import ( - LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter) + LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter, + BandPassFilter) import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component import homeassistant.core as ha @@ -135,9 +136,10 @@ def test_bandpass(self): """Test if bandpass filter works.""" lower = 10 upper = 20 - filt = LowPassFilter(entity=None, - lower_bound=lower, - upper_bound=upper) + filt = BandPassFilter(1, None, + entity=None, + lower_bound=lower, + upper_bound=upper) for state in self.values: filtered = filt.filter_state(state) if state < lower: From 734a83c65708f39d4629cb52df306c6a2746df79 Mon Sep 17 00:00:00 2001 From: nielstron Date: Thu, 22 Mar 2018 22:22:53 +0100 Subject: [PATCH 005/147] Removed default values and fixed description in sensor.filter --- homeassistant/components/sensor/filter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 4505a794883418..9346480b4b39c6 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -355,7 +355,7 @@ class BandPassFilter(Filter): """ def __init__(self, window_size, precision, entity, - lower_bound=math.inf, upper_bound=-math.inf): + lower_bound, upper_bound): """Initialize Filter.""" super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) self._lower_bound = lower_bound @@ -363,7 +363,7 @@ def __init__(self, window_size, precision, entity, self._stats_internal = Counter() def _filter_state(self, new_state): - """Implement the outlier filter.""" + """Implement the band-pass filter.""" new_state = float(new_state) if new_state > self._upper_bound: From a0ab35693647b216eb2fd74e2b19a06abe47b978 Mon Sep 17 00:00:00 2001 From: nielstron Date: Fri, 30 Mar 2018 14:03:38 +0200 Subject: [PATCH 006/147] Renamed to range filter --- homeassistant/components/sensor/filter.py | 20 ++++++++++---------- tests/components/sensor/test_filter.py | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 9346480b4b39c6..f1fb1fca6f3f67 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) -FILTER_NAME_BANDPASS = 'bandpass' +FILTER_NAME_RANGE = 'range' FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' FILTER_NAME_THROTTLE = 'throttle' @@ -83,8 +83,8 @@ default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), }) -FILTER_BANDPASS_SCHEMA = FILTER_SCHEMA.extend({ - vol.Required(CONF_FILTER_NAME): FILTER_NAME_BANDPASS, +FILTER_RANGE_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_RANGE, vol.Optional(CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_LOWER_BOUND, @@ -117,7 +117,7 @@ FILTER_LOWPASS_SCHEMA, FILTER_TIME_SMA_SCHEMA, FILTER_THROTTLE_SCHEMA, - FILTER_BANDPASS_SCHEMA)]) + FILTER_RANGE_SCHEMA)]) }) @@ -342,11 +342,11 @@ def filter_state(self, new_state): return new_state -@FILTERS.register(FILTER_NAME_BANDPASS) -class BandPassFilter(Filter): - """Band pass filter. +@FILTERS.register(FILTER_NAME_RANGE) +class RangeFilter(Filter): + """Range filter. - Determines if new state is in a band between upper_bound and lower_bound. + Determines if new state is in the range of upper_bound and lower_bound. If not inside, lower or upper bound is returned instead. Args: @@ -357,13 +357,13 @@ class BandPassFilter(Filter): def __init__(self, window_size, precision, entity, lower_bound, upper_bound): """Initialize Filter.""" - super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + super().__init__(FILTER_NAME_RANGE, window_size, precision, entity) self._lower_bound = lower_bound self._upper_bound = upper_bound self._stats_internal = Counter() def _filter_state(self, new_state): - """Implement the band-pass filter.""" + """Implement the range filter.""" new_state = float(new_state) if new_state > self._upper_bound: diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index e43df67c84f13c..7b23210128912d 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -5,7 +5,7 @@ from homeassistant.components.sensor.filter import ( LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter, - BandPassFilter) + RangeFilter) import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component import homeassistant.core as ha @@ -132,11 +132,11 @@ def test_lowpass(self): filtered = filt.filter_state(state) self.assertEqual(18.05, filtered.state) - def test_bandpass(self): - """Test if bandpass filter works.""" + def test_range(self): + """Test if range filter works.""" lower = 10 upper = 20 - filt = BandPassFilter(1, None, + filt = RangeFilter(1, None, entity=None, lower_bound=lower, upper_bound=upper) From ba836c2e3629257b4461202da5f6f5db7ed667e2 Mon Sep 17 00:00:00 2001 From: nielstron Date: Fri, 30 Mar 2018 14:10:22 +0200 Subject: [PATCH 007/147] Fix indent --- tests/components/sensor/test_filter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 7b23210128912d..4d52648582f379 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -137,9 +137,9 @@ def test_range(self): lower = 10 upper = 20 filt = RangeFilter(1, None, - entity=None, - lower_bound=lower, - upper_bound=upper) + entity=None, + lower_bound=lower, + upper_bound=upper) for state in self.values: filtered = filt.filter_state(state) if state < lower: From f4ef8fd1bc95ced0352ef22486e16fcc477306bb Mon Sep 17 00:00:00 2001 From: nielstron Date: Sat, 28 Apr 2018 17:42:32 +0200 Subject: [PATCH 008/147] Changes for new FilterState construct --- homeassistant/components/sensor/filter.py | 9 ++++----- tests/components/sensor/test_filter.py | 14 +++++++------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index f1fb1fca6f3f67..88868ddcc42720 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -364,25 +364,24 @@ def __init__(self, window_size, precision, entity, def _filter_state(self, new_state): """Implement the range filter.""" - new_state = float(new_state) - if new_state > self._upper_bound: + if new_state.state > self._upper_bound: self._stats_internal['erasures_up'] += 1 _LOGGER.debug("Upper outlier nr. %s in %s: %s", self._stats_internal['erasures_up'], self._entity, new_state) - return self._upper_bound + new_state.state = self._upper_bound - if new_state < self._lower_bound: + elif new_state < self._lower_bound: self._stats_internal['erasures_low'] += 1 _LOGGER.debug("Lower outlier nr. %s in %s: %s", self._stats_internal['erasures_low'], self._entity, new_state) - return self._lower_bound + new_state.state = self._upper_bound return new_state diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 4d52648582f379..0f5b581d075c91 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -140,14 +140,14 @@ def test_range(self): entity=None, lower_bound=lower, upper_bound=upper) - for state in self.values: - filtered = filt.filter_state(state) - if state < lower: - self.assertEqual(lower, filtered) - elif state > upper: - self.assertEqual(upper, filtered) + for unf_state in self.values: + filtered = filt.filter_state(unf_state) + if unf_state.state < lower: + self.assertEqual(lower, filtered.state) + elif unf_state.state > upper: + self.assertEqual(upper, filtered.state) else: - self.assertEqual(state, filtered) + self.assertEqual(unf_state.state, filtered.state) def test_throttle(self): """Test if lowpass filter works.""" From 07d139b3a82446ad8eaee304e59c947c762fd285 Mon Sep 17 00:00:00 2001 From: nielstron Date: Sat, 28 Apr 2018 17:51:04 +0200 Subject: [PATCH 009/147] Fix wrong comparison --- homeassistant/components/sensor/filter.py | 2 +- tests/components/sensor/test_filter.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 88868ddcc42720..21fd09248ff015 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -374,7 +374,7 @@ def _filter_state(self, new_state): self._entity, new_state) new_state.state = self._upper_bound - elif new_state < self._lower_bound: + elif new_state.state < self._lower_bound: self._stats_internal['erasures_low'] += 1 diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 0f5b581d075c91..e449f239c0bc4d 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -141,13 +141,14 @@ def test_range(self): lower_bound=lower, upper_bound=upper) for unf_state in self.values: + prev = unf_state.state filtered = filt.filter_state(unf_state) - if unf_state.state < lower: + if prev < lower: self.assertEqual(lower, filtered.state) - elif unf_state.state > upper: + elif prev > upper: self.assertEqual(upper, filtered.state) else: - self.assertEqual(unf_state.state, filtered.state) + self.assertEqual(prev, filtered.state) def test_throttle(self): """Test if lowpass filter works.""" From bb98331ba45f9c2a4845316d21659ceabbf7ebd3 Mon Sep 17 00:00:00 2001 From: nielstron Date: Sat, 28 Apr 2018 18:09:37 +0200 Subject: [PATCH 010/147] Fix doctring newline and handle ha.state string-being --- homeassistant/components/sensor/filter.py | 1 - tests/components/sensor/test_filter.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 21fd09248ff015..93380291a3eb97 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -364,7 +364,6 @@ def __init__(self, window_size, precision, entity, def _filter_state(self, new_state): """Implement the range filter.""" - if new_state.state > self._upper_bound: self._stats_internal['erasures_up'] += 1 diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index e449f239c0bc4d..6fc732b28f5e39 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -141,7 +141,7 @@ def test_range(self): lower_bound=lower, upper_bound=upper) for unf_state in self.values: - prev = unf_state.state + prev = float(unf_state.state) filtered = filt.filter_state(unf_state) if prev < lower: self.assertEqual(lower, filtered.state) From 25f7c31911ffb6c1a646468b507a93c84831ecb9 Mon Sep 17 00:00:00 2001 From: nielstron Date: Sat, 28 Apr 2018 18:29:55 +0200 Subject: [PATCH 011/147] Fixed wrong bound assignment on values below the lower bound --- homeassistant/components/sensor/filter.py | 2 +- tests/components/sensor/test_filter.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 93380291a3eb97..49e9189a84d7dc 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -380,7 +380,7 @@ def _filter_state(self, new_state): _LOGGER.debug("Lower outlier nr. %s in %s: %s", self._stats_internal['erasures_low'], self._entity, new_state) - new_state.state = self._upper_bound + new_state.state = self._lower_bound return new_state diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 6fc732b28f5e39..718c39764d0e6a 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -141,14 +141,14 @@ def test_range(self): lower_bound=lower, upper_bound=upper) for unf_state in self.values: - prev = float(unf_state.state) + unf = float(unf_state.state) filtered = filt.filter_state(unf_state) - if prev < lower: + if unf < lower: self.assertEqual(lower, filtered.state) - elif prev > upper: + elif unf > upper: self.assertEqual(upper, filtered.state) else: - self.assertEqual(prev, filtered.state) + self.assertEqual(unf, filtered.state) def test_throttle(self): """Test if lowpass filter works.""" From 8061f15aec88332a8296c0f4c9ea3e278c4b0ea0 Mon Sep 17 00:00:00 2001 From: nielstron Date: Fri, 4 May 2018 00:51:03 +0200 Subject: [PATCH 012/147] Removal of windows size and precision for range filter --- homeassistant/components/sensor/filter.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 49e9189a84d7dc..770287228a2ed3 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -85,8 +85,6 @@ FILTER_RANGE_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_RANGE, - vol.Optional(CONF_FILTER_WINDOW_SIZE, - default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_LOWER_BOUND, default=DEFAULT_LOWER_BOUND): vol.Coerce(float), vol.Optional(CONF_FILTER_UPPER_BOUND, @@ -354,10 +352,10 @@ class RangeFilter(Filter): lower_bound (float): band lower bound """ - def __init__(self, window_size, precision, entity, + def __init__(self, entity, lower_bound, upper_bound): """Initialize Filter.""" - super().__init__(FILTER_NAME_RANGE, window_size, precision, entity) + super().__init__(FILTER_NAME_RANGE, entity=entity) self._lower_bound = lower_bound self._upper_bound = upper_bound self._stats_internal = Counter() From 33990badcd3955de50a408bb848d24b42f863241 Mon Sep 17 00:00:00 2001 From: nielstron Date: Fri, 4 May 2018 10:30:44 +0200 Subject: [PATCH 013/147] Fixed Rangefilter constructor call --- tests/components/sensor/test_filter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 718c39764d0e6a..cf2cc9c42054db 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -136,8 +136,7 @@ def test_range(self): """Test if range filter works.""" lower = 10 upper = 20 - filt = RangeFilter(1, None, - entity=None, + filt = RangeFilter(entity=None, lower_bound=lower, upper_bound=upper) for unf_state in self.values: From 10d1e81f10f061df995c3f4bdde3386069791e91 Mon Sep 17 00:00:00 2001 From: Leonardo Brondani Schenkel Date: Sat, 30 Jun 2018 00:59:10 +0200 Subject: [PATCH 014/147] deconz: fix light.turn_off with transition (#15222) When light.turn_off is invoked with a transition, the following payload was sent to deCONZ via PUT to /light/N/state: { "bri": 0, "transitiontime": transition } However, on recent versions of deCONZ (latest is 2.05.31 at the time of writing) this does not turn off the light, just sets it to minimum brightness level (brightness is clamped to minimum level the light supports without turning it off). This commit makes the code send this payload instead: { "on": false, "transitiontime": transition } This works as intended and the light does transition to the 'off' state. This change was tested with Philips Hue colored lights, IKEA colored lights and IKEA white spectrum lights: they were all able to be turned off successfully with the new payload, and none of them could be turned off with the old payload. --- homeassistant/components/light/deconz.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 05907ea86ee275..d6078490e7f960 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -174,7 +174,6 @@ async def async_turn_off(self, **kwargs): data = {'on': False} if ATTR_TRANSITION in kwargs: - data = {'bri': 0} data['transitiontime'] = int(kwargs[ATTR_TRANSITION]) * 10 if ATTR_FLASH in kwargs: From 27a37e2013bbdc46cf4d249c957325829d701c89 Mon Sep 17 00:00:00 2001 From: pepeEL Date: Sat, 30 Jun 2018 14:56:43 +0200 Subject: [PATCH 015/147] Add new RTS device (#15116) * Add new RTS device Add new RTS Somfy device as cover-ExteriorVenetianBlindRTSComponent * add next device add next device --- homeassistant/components/tahoma.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 84edd9afd40990..ba91dd7c1fc27d 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -40,6 +40,8 @@ 'rts:CurtainRTSComponent': 'cover', 'rts:BlindRTSComponent': 'cover', 'rts:VenetianBlindRTSComponent': 'cover', + 'rts:DualCurtainRTSComponent': 'cover', + 'rts:ExteriorVenetianBlindRTSComponent': 'cover', 'io:ExteriorVenetianBlindIOComponent': 'cover', 'io:RollerShutterUnoIOComponent': 'cover', 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', From c5ceb40598f167d6726d5f6700dfb3365948dd36 Mon Sep 17 00:00:00 2001 From: Carl Chan Date: Sat, 30 Jun 2018 08:57:48 -0400 Subject: [PATCH 016/147] Add additional parameters to NUT UPS sensor (#15066) * Update nut.py Added input.frequency and a number of output parameters. * Update nut.py Fixed formatting issues Added "devices" fields * Separated "device.description" line to two lines. * Update nut.py Removed device.* sensors --- homeassistant/components/sensor/nut.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index bf440728a2ee55..7c7ff3480b00e0 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -107,6 +107,20 @@ ['Voltage Transfer Reason', '', 'mdi:information-outline'], 'input.voltage': ['Input Voltage', 'V', 'mdi:flash'], 'input.voltage.nominal': ['Nominal Input Voltage', 'V', 'mdi:flash'], + 'input.frequency': ['Input Line Frequency', 'hz', 'mdi:flash'], + 'input.frequency.nominal': + ['Nominal Input Line Frequency', 'hz', 'mdi:flash'], + 'input.frequency.status': + ['Input Frequency Status', '', 'mdi:information-outline'], + 'output.current': ['Output Current', 'A', 'mdi:flash'], + 'output.current.nominal': + ['Nominal Output Current', 'A', 'mdi:flash'], + 'output.voltage': ['Output Voltage', 'V', 'mdi:flash'], + 'output.voltage.nominal': + ['Nominal Output Voltage', 'V', 'mdi:flash'], + 'output.frequency': ['Output Frequency', 'hz', 'mdi:flash'], + 'output.frequency.nominal': + ['Nominal Output Frequency', 'hz', 'mdi:flash'], } STATE_TYPES = { From 0aad056ca75113c202841d1bee72f181dd857357 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 30 Jun 2018 17:12:00 +0200 Subject: [PATCH 017/147] Fix typos (#15233) --- homeassistant/components/watson_iot.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/watson_iot.py b/homeassistant/components/watson_iot.py index 246cf3a96c28ea..889984eb223bf8 100644 --- a/homeassistant/components/watson_iot.py +++ b/homeassistant/components/watson_iot.py @@ -4,7 +4,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/watson_iot/ """ - import logging import queue import threading @@ -13,8 +12,8 @@ import voluptuous as vol from homeassistant.const import ( - CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, - CONF_TOKEN, CONF_TYPE, EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_STOP, + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ID, CONF_INCLUDE, + CONF_TOKEN, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN) from homeassistant.helpers import state as state_helper import homeassistant.helpers.config_validation as cv @@ -24,13 +23,13 @@ _LOGGER = logging.getLogger(__name__) CONF_ORG = 'organization' -CONF_ID = 'id' DOMAIN = 'watson_iot' -RETRY_DELAY = 20 MAX_TRIES = 3 +RETRY_DELAY = 20 + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All(vol.Schema({ vol.Required(CONF_ORG): cv.string, @@ -103,7 +102,7 @@ def event_to_json(event): }, 'time': event.time_fired.isoformat(), 'fields': { - 'state': state.state + 'state': state.state, } } if _state_as_value is not None: @@ -113,7 +112,7 @@ def event_to_json(event): if key != 'unit_of_measurement': # If the key is already in fields if key in out_event['fields']: - key = key + "_" + key = '{}_'.format(key) # For each value we try to cast it as float # But if we can not do it we store the value # as string @@ -153,7 +152,7 @@ def __init__(self, hass, gateway, event_to_json): hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) def _event_listener(self, event): - """Listen for new messages on the bus and queue them for Watson IOT.""" + """Listen for new messages on the bus and queue them for Watson IoT.""" item = (time.monotonic(), event) self.queue.put(item) @@ -191,7 +190,7 @@ def write_to_watson(self, events): field, 'json', value) if not device_success: _LOGGER.error( - "Failed to publish message to watson iot") + "Failed to publish message to Watson IoT") continue break except (ibmiotf.MissingMessageEncoderException, IOError): @@ -199,7 +198,7 @@ def write_to_watson(self, events): time.sleep(RETRY_DELAY) else: _LOGGER.exception( - "Failed to publish message to watson iot") + "Failed to publish message to Watson IoT") def run(self): """Process incoming events.""" From 3da46421949fde5fc0509565ce6296b15c9eac9a Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Sat, 30 Jun 2018 18:10:59 +0200 Subject: [PATCH 018/147] Use async syntax for cover platforms (#15230) --- .../components/cover/lutron_caseta.py | 17 +++---- homeassistant/components/cover/mqtt.py | 35 +++++-------- homeassistant/components/cover/rflink.py | 5 +- homeassistant/components/cover/template.py | 50 ++++++++----------- homeassistant/components/cover/velbus.py | 6 +-- homeassistant/components/cover/wink.py | 5 +- 6 files changed, 44 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 1ed502e0f7f8af..87821b802ba6b9 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ -import asyncio import logging from homeassistant.components.cover import ( @@ -18,8 +17,8 @@ DEPENDENCIES = ['lutron_caseta'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -49,25 +48,21 @@ def current_cover_position(self): """Return the current position of cover.""" return self._state['current_state'] - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] self._smartbridge.set_value(self._device_id, position) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 235ff5799cc810..62e1069e18bb32 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mqtt/ """ -import asyncio import logging import voluptuous as vol @@ -93,8 +92,8 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the MQTT Cover.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -174,10 +173,9 @@ def __init__(self, name, state_topic, command_topic, availability_topic, self._position_topic = position_topic self._set_position_template = set_position_template - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() @callback def tilt_updated(topic, payload, qos): @@ -218,7 +216,7 @@ def state_message_received(topic, payload, qos): # Force into optimistic mode. self._optimistic = True else: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._state_topic, state_message_received, self._qos) @@ -227,7 +225,7 @@ def state_message_received(topic, payload, qos): else: self._tilt_optimistic = False self._tilt_value = STATE_UNKNOWN - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._tilt_status_topic, tilt_updated, self._qos) @property @@ -278,8 +276,7 @@ def supported_features(self): return supported_features - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up. This method is a coroutine. @@ -292,8 +289,7 @@ def async_open_cover(self, **kwargs): self._state = False self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down. This method is a coroutine. @@ -306,8 +302,7 @@ def async_close_cover(self, **kwargs): self._state = True self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the device. This method is a coroutine. @@ -316,8 +311,7 @@ def async_stop_cover(self, **kwargs): self.hass, self._command_topic, self._payload_stop, self._qos, self._retain) - @asyncio.coroutine - def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" mqtt.async_publish(self.hass, self._tilt_command_topic, self._tilt_open_position, self._qos, @@ -326,8 +320,7 @@ def async_open_cover_tilt(self, **kwargs): self._tilt_value = self._tilt_open_position self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" mqtt.async_publish(self.hass, self._tilt_command_topic, self._tilt_closed_position, self._qos, @@ -336,8 +329,7 @@ def async_close_cover_tilt(self, **kwargs): self._tilt_value = self._tilt_closed_position self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION not in kwargs: return @@ -350,8 +342,7 @@ def async_set_cover_tilt_position(self, **kwargs): mqtt.async_publish(self.hass, self._tilt_command_topic, level, self._qos, self._retain) - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] diff --git a/homeassistant/components/cover/rflink.py b/homeassistant/components/cover/rflink.py index a9b7598159f967..3357bf2d204fb0 100644 --- a/homeassistant/components/cover/rflink.py +++ b/homeassistant/components/cover/rflink.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.rflink/ """ -import asyncio import logging import voluptuous as vol @@ -79,8 +78,8 @@ def devices_from_config(domain_config, hass=None): return devices -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Rflink cover platform.""" async_add_devices(devices_from_config(config, hass)) diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 4e197365a7098e..d9d0d61c77a8f0 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.template/ """ -import asyncio import logging import voluptuous as vol @@ -72,8 +71,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Template cover.""" covers = [] @@ -199,8 +198,7 @@ def __init__(self, hass, device_id, friendly_name, state_template, if self._entity_picture_template is not None: self._entity_picture_template.hass = self.hass - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Register callbacks.""" @callback def template_cover_state_listener(entity, old_state, new_state): @@ -277,70 +275,62 @@ def should_poll(self): """Return the polling state.""" return False - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up.""" if self._open_script: - yield from self._open_script.async_run() + await self._open_script.async_run() elif self._position_script: - yield from self._position_script.async_run({"position": 100}) + await self._position_script.async_run({"position": 100}) if self._optimistic: self._position = 100 self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down.""" if self._close_script: - yield from self._close_script.async_run() + await self._close_script.async_run() elif self._position_script: - yield from self._position_script.async_run({"position": 0}) + await self._position_script.async_run({"position": 0}) if self._optimistic: self._position = 0 self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Fire the stop action.""" if self._stop_script: - yield from self._stop_script.async_run() + await self._stop_script.async_run() - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Set cover position.""" self._position = kwargs[ATTR_POSITION] - yield from self._position_script.async_run( + await self._position_script.async_run( {"position": self._position}) if self._optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" self._tilt_value = 100 - yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + await self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" self._tilt_value = 0 - yield from self._tilt_script.async_run( + await self._tilt_script.async_run( {"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] - yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + await self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update the state from the template.""" if self._template is not None: try: diff --git a/homeassistant/components/cover/velbus.py b/homeassistant/components/cover/velbus.py index ab5d6e8ef7947a..fd060e7a7e1c07 100644 --- a/homeassistant/components/cover/velbus.py +++ b/homeassistant/components/cover/velbus.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/cover.velbus/ """ import logging -import asyncio import time import voluptuous as vol @@ -70,15 +69,14 @@ def __init__(self, velbus, name, module, open_channel, close_channel): self._open_channel = open_channel self._close_channel = close_channel - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Add listener for Velbus messages on bus.""" def _init_velbus(): """Initialize Velbus on startup.""" self._velbus.subscribe(self._on_message) self.get_status() - yield from self.hass.async_add_job(_init_velbus) + await self.hass.async_add_job(_init_velbus) def _on_message(self, message): import velbus diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 7f7a3a116443a7..2206de05041435 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -4,8 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.wink/ """ -import asyncio - from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN, \ ATTR_POSITION from homeassistant.components.wink import WinkDevice, DOMAIN @@ -34,8 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class WinkCoverDevice(WinkDevice, CoverDevice): """Representation of a Wink cover device.""" - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['cover'].append(self) From f874efb224fd2133793234ff924d7e445f013cc9 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sat, 30 Jun 2018 19:31:36 -0700 Subject: [PATCH 019/147] By default to use access_token if hass.auth.active (#15212) * Force to use access_token if hass.auth.active * Not allow Basic auth with api_password if hass.auth.active * Block websocket api_password auth when hass.auth.active * Add legacy_api_password auth provider * lint * lint --- homeassistant/auth.py | 14 +- .../auth_providers/legacy_api_password.py | 104 +++++++++++++ homeassistant/components/http/__init__.py | 17 +- homeassistant/components/http/auth.py | 66 ++++---- homeassistant/components/websocket_api.py | 24 +-- .../test_legacy_api_password.py | 67 ++++++++ tests/components/http/test_auth.py | 147 ++++++++++++++++-- tests/components/test_websocket_api.py | 108 +++++++++---- 8 files changed, 466 insertions(+), 81 deletions(-) create mode 100644 homeassistant/auth_providers/legacy_api_password.py create mode 100644 tests/auth_providers/test_legacy_api_password.py diff --git a/homeassistant/auth.py b/homeassistant/auth.py index 22abcdf213ccc2..f56e00bf31e553 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -280,6 +280,18 @@ def active(self): """Return if any auth providers are registered.""" return bool(self._providers) + @property + def support_legacy(self): + """ + Return if legacy_api_password auth providers are registered. + + Should be removed when we removed legacy_api_password auth providers. + """ + for provider_type, _ in self._providers: + if provider_type == 'legacy_api_password': + return True + return False + @property def async_auth_providers(self): """Return a list of available auth providers.""" @@ -534,7 +546,7 @@ async def async_load(self): client_id=rt_dict['client_id'], created_at=dt_util.parse_datetime(rt_dict['created_at']), access_token_expiration=timedelta( - rt_dict['access_token_expiration']), + seconds=rt_dict['access_token_expiration']), token=rt_dict['token'], ) refresh_tokens[token.id] = token diff --git a/homeassistant/auth_providers/legacy_api_password.py b/homeassistant/auth_providers/legacy_api_password.py new file mode 100644 index 00000000000000..510cc4d02792fe --- /dev/null +++ b/homeassistant/auth_providers/legacy_api_password.py @@ -0,0 +1,104 @@ +""" +Support Legacy API password auth provider. + +It will be removed when auth system production ready +""" +from collections import OrderedDict +import hmac + +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError +from homeassistant import auth, data_entry_flow +from homeassistant.core import callback + +USER_SCHEMA = vol.Schema({ + vol.Required('username'): str, +}) + + +CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ +}, extra=vol.PREVENT_EXTRA) + +LEGACY_USER = 'homeassistant' + + +class InvalidAuthError(HomeAssistantError): + """Raised when submitting invalid authentication.""" + + +@auth.AUTH_PROVIDERS.register('legacy_api_password') +class LegacyApiPasswordAuthProvider(auth.AuthProvider): + """Example auth provider based on hardcoded usernames and passwords.""" + + DEFAULT_TITLE = 'Legacy API Password' + + async def async_credential_flow(self): + """Return a flow to login.""" + return LoginFlow(self) + + @callback + def async_validate_login(self, password): + """Helper to validate a username and password.""" + if not hasattr(self.hass, 'http'): + raise ValueError('http component is not loaded') + + if self.hass.http.api_password is None: + raise ValueError('http component is not configured using' + ' api_password') + + if not hmac.compare_digest(self.hass.http.api_password.encode('utf-8'), + password.encode('utf-8')): + raise InvalidAuthError + + async def async_get_or_create_credentials(self, flow_result): + """Return LEGACY_USER always.""" + for credential in await self.async_credentials(): + if credential.data['username'] == LEGACY_USER: + return credential + + return self.async_create_credentials({ + 'username': LEGACY_USER + }) + + async def async_user_meta_for_credentials(self, credentials): + """ + Set name as LEGACY_USER always. + + Will be used to populate info when creating a new user. + """ + return {'name': LEGACY_USER} + + +class LoginFlow(data_entry_flow.FlowHandler): + """Handler for the login flow.""" + + def __init__(self, auth_provider): + """Initialize the login flow.""" + self._auth_provider = auth_provider + + async def async_step_init(self, user_input=None): + """Handle the step of the form.""" + errors = {} + + if user_input is not None: + try: + self._auth_provider.async_validate_login( + user_input['password']) + except InvalidAuthError: + errors['base'] = 'invalid_auth' + + if not errors: + return self.async_create_entry( + title=self._auth_provider.name, + data={} + ) + + schema = OrderedDict() + schema['password'] = str + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 485433434fd0dd..37a6805dfb58b8 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -184,7 +184,22 @@ def __init__(self, hass, api_password, if is_ban_enabled: setup_bans(hass, app, login_threshold) - setup_auth(app, trusted_networks, api_password) + if hass.auth.active: + if hass.auth.support_legacy: + _LOGGER.warning("Experimental auth api enabled and " + "legacy_api_password support enabled. Please " + "use access_token instead api_password, " + "although you can still use legacy " + "api_password") + else: + _LOGGER.warning("Experimental auth api enabled. Please use " + "access_token instead api_password.") + elif api_password is None: + _LOGGER.warning("You have been advised to set http.api_password.") + + setup_auth(app, trusted_networks, hass.auth.active, + support_legacy=hass.auth.support_legacy, + api_password=api_password) if cors_origins: setup_cors(app, cors_origins) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index c4723abccee348..a232d9295a4d7f 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -17,37 +17,44 @@ @callback -def setup_auth(app, trusted_networks, api_password): +def setup_auth(app, trusted_networks, use_auth, + support_legacy=False, api_password=None): """Create auth middleware for the app.""" @middleware async def auth_middleware(request, handler): """Authenticate as middleware.""" - # If no password set, just always set authenticated=True - if api_password is None: - request[KEY_AUTHENTICATED] = True - return await handler(request) - - # Check authentication authenticated = False - if (HTTP_HEADER_HA_AUTH in request.headers and - hmac.compare_digest( - api_password.encode('utf-8'), - request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): + if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or + DATA_API_PASSWORD in request.query): + _LOGGER.warning('Please use access_token instead api_password.') + + legacy_auth = (not use_auth or support_legacy) and api_password + if (hdrs.AUTHORIZATION in request.headers and + await async_validate_auth_header( + request, api_password if legacy_auth else None)): + # it included both use_auth and api_password Basic auth + authenticated = True + + elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and + hmac.compare_digest( + api_password.encode('utf-8'), + request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): # A valid auth header has been set authenticated = True - elif (DATA_API_PASSWORD in request.query and + elif (legacy_auth and DATA_API_PASSWORD in request.query and hmac.compare_digest( api_password.encode('utf-8'), request.query[DATA_API_PASSWORD].encode('utf-8'))): authenticated = True - elif (hdrs.AUTHORIZATION in request.headers and - await async_validate_auth_header(api_password, request)): + elif _is_trusted_ip(request, trusted_networks): authenticated = True - elif _is_trusted_ip(request, trusted_networks): + elif not use_auth and api_password is None: + # If neither password nor auth_providers set, + # just always set authenticated=True authenticated = True request[KEY_AUTHENTICATED] = authenticated @@ -76,8 +83,12 @@ def validate_password(request, api_password): request.app['hass'].http.api_password.encode('utf-8')) -async def async_validate_auth_header(api_password, request): - """Test an authorization header if valid password.""" +async def async_validate_auth_header(request, api_password=None): + """ + Test authorization header against access token. + + Basic auth_type is legacy code, should be removed with api_password. + """ if hdrs.AUTHORIZATION not in request.headers: return False @@ -88,7 +99,16 @@ async def async_validate_auth_header(api_password, request): # If no space in authorization header return False - if auth_type == 'Basic': + if auth_type == 'Bearer': + hass = request.app['hass'] + access_token = hass.auth.async_get_access_token(auth_val) + if access_token is None: + return False + + request['hass_user'] = access_token.refresh_token.user + return True + + elif auth_type == 'Basic' and api_password is not None: decoded = base64.b64decode(auth_val).decode('utf-8') try: username, password = decoded.split(':', 1) @@ -102,13 +122,5 @@ async def async_validate_auth_header(api_password, request): return hmac.compare_digest(api_password.encode('utf-8'), password.encode('utf-8')) - if auth_type != 'Bearer': + else: return False - - hass = request.app['hass'] - access_token = hass.auth.async_get_access_token(auth_val) - if access_token is None: - return False - - request['hass_user'] = access_token.refresh_token.user - return True diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index bf472348babf8c..c26f68a2c29f0f 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -315,26 +315,32 @@ def handle_hass_stop(event): authenticated = True else: + self.debug("Request auth") await self.wsock.send_json(auth_required_message()) msg = await wsock.receive_json() msg = AUTH_MESSAGE_SCHEMA(msg) - if 'api_password' in msg: - authenticated = validate_password( - request, msg['api_password']) - - elif 'access_token' in msg: + if self.hass.auth.active and 'access_token' in msg: + self.debug("Received access_token") token = self.hass.auth.async_get_access_token( msg['access_token']) authenticated = token is not None + elif ((not self.hass.auth.active or + self.hass.auth.support_legacy) and + 'api_password' in msg): + self.debug("Received api_password") + authenticated = validate_password( + request, msg['api_password']) + if not authenticated: - self.debug("Invalid password") + self.debug("Authorization failed") await self.wsock.send_json( - auth_invalid_message('Invalid password')) + auth_invalid_message('Invalid access token or password')) await process_wrong_login(request) return wsock + self.debug("Auth OK") await self.wsock.send_json(auth_ok_message()) # ---------- AUTH PHASE OVER ---------- @@ -392,7 +398,7 @@ def handle_hass_stop(event): if wsock.closed: self.debug("Connection closed by client") else: - _LOGGER.exception("Unexpected TypeError: %s", msg) + _LOGGER.exception("Unexpected TypeError: %s", err) except ValueError as err: msg = "Received invalid JSON" @@ -403,7 +409,7 @@ def handle_hass_stop(event): self._writer_task.cancel() except CANCELLATION_ERRORS: - self.debug("Connection cancelled by server") + self.debug("Connection cancelled") except asyncio.QueueFull: self.log_error("Client exceeded max pending messages [1]:", diff --git a/tests/auth_providers/test_legacy_api_password.py b/tests/auth_providers/test_legacy_api_password.py new file mode 100644 index 00000000000000..7a8f17894aa1fc --- /dev/null +++ b/tests/auth_providers/test_legacy_api_password.py @@ -0,0 +1,67 @@ +"""Tests for the legacy_api_password auth provider.""" +from unittest.mock import Mock + +import pytest + +from homeassistant import auth +from homeassistant.auth_providers import legacy_api_password + + +@pytest.fixture +def store(hass): + """Mock store.""" + return auth.AuthStore(hass) + + +@pytest.fixture +def provider(hass, store): + """Mock provider.""" + return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, { + 'type': 'legacy_api_password', + }) + + +async def test_create_new_credential(provider): + """Test that we create a new credential.""" + credentials = await provider.async_get_or_create_credentials({}) + assert credentials.data["username"] is legacy_api_password.LEGACY_USER + assert credentials.is_new is True + + +async def test_only_one_credentials(store, provider): + """Call create twice will return same credential.""" + credentials = await provider.async_get_or_create_credentials({}) + await store.async_get_or_create_user(credentials, provider) + credentials2 = await provider.async_get_or_create_credentials({}) + assert credentials2.data["username"] is legacy_api_password.LEGACY_USER + assert credentials2.id is credentials.id + assert credentials2.is_new is False + + +async def test_verify_not_load(hass, provider): + """Test we raise if http module not load.""" + with pytest.raises(ValueError): + provider.async_validate_login('test-password') + hass.http = Mock(api_password=None) + with pytest.raises(ValueError): + provider.async_validate_login('test-password') + hass.http = Mock(api_password='test-password') + provider.async_validate_login('test-password') + + +async def test_verify_login(hass, provider): + """Test we raise if http module not load.""" + hass.http = Mock(api_password='test-password') + provider.async_validate_login('test-password') + hass.http = Mock(api_password='test-password') + with pytest.raises(legacy_api_password.InvalidAuthError): + provider.async_validate_login('invalid-password') + + +async def test_utf_8_username_password(provider): + """Test that we create a new credential.""" + credentials = await provider.async_get_or_create_credentials({ + 'username': '🎉', + 'password': '😎', + }) + assert credentials.is_new is True diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index dd8b2cd35c46c2..3e5eed4c924b55 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,20 +1,23 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access from ipaddress import ip_network -from unittest.mock import patch +from unittest.mock import patch, Mock +import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -import pytest -from homeassistant.const import HTTP_HEADER_HA_AUTH -from homeassistant.setup import async_setup_component +from homeassistant.auth import AccessToken, RefreshToken from homeassistant.components.http.auth import setup_auth -from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.components.http.const import KEY_AUTHENTICATED - +from homeassistant.components.http.real_ip import setup_real_ip +from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.setup import async_setup_component from . import mock_real_ip + +ACCESS_TOKEN = 'tk.1234' + API_PASSWORD = 'test1234' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases @@ -36,15 +39,37 @@ async def mock_handler(request): return web.Response(status=200) +def mock_async_get_access_token(token): + """Return if token is valid.""" + if token == ACCESS_TOKEN: + return Mock(spec=AccessToken, + token=ACCESS_TOKEN, + refresh_token=Mock(spec=RefreshToken)) + else: + return None + + @pytest.fixture def app(): """Fixture to setup a web.Application.""" app = web.Application() + mock_auth = Mock(async_get_access_token=mock_async_get_access_token) + app['hass'] = Mock(auth=mock_auth) app.router.add_get('/', mock_handler) setup_real_ip(app, False, []) return app +@pytest.fixture +def app2(): + """Fixture to setup a web.Application without real_ip middleware.""" + app = web.Application() + mock_auth = Mock(async_get_access_token=mock_async_get_access_token) + app['hass'] = Mock(auth=mock_auth) + app.router.add_get('/', mock_handler) + return app + + async def test_auth_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_auth') as mock_setup: @@ -57,7 +82,7 @@ async def test_auth_middleware_loaded_by_default(hass): async def test_access_without_password(app, aiohttp_client): """Test access without password.""" - setup_auth(app, [], None) + setup_auth(app, [], False, api_password=None) client = await aiohttp_client(app) resp = await client.get('/') @@ -65,8 +90,8 @@ async def test_access_without_password(app, aiohttp_client): async def test_access_with_password_in_header(app, aiohttp_client): - """Test access with password in URL.""" - setup_auth(app, [], API_PASSWORD) + """Test access with password in header.""" + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) req = await client.get( @@ -79,8 +104,8 @@ async def test_access_with_password_in_header(app, aiohttp_client): async def test_access_with_password_in_query(app, aiohttp_client): - """Test access without password.""" - setup_auth(app, [], API_PASSWORD) + """Test access with password in URL.""" + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) resp = await client.get('/', params={ @@ -99,7 +124,7 @@ async def test_access_with_password_in_query(app, aiohttp_client): async def test_basic_auth_works(app, aiohttp_client): """Test access with basic authentication.""" - setup_auth(app, [], API_PASSWORD) + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) req = await client.get( @@ -125,16 +150,64 @@ async def test_basic_auth_works(app, aiohttp_client): assert req.status == 401 -async def test_access_with_trusted_ip(aiohttp_client): +async def test_access_with_trusted_ip(app2, aiohttp_client): """Test access with an untrusted ip address.""" - app = web.Application() - app.router.add_get('/', mock_handler) + setup_auth(app2, TRUSTED_NETWORKS, False, api_password='some-pass') - setup_auth(app, TRUSTED_NETWORKS, 'some-pass') + set_mock_ip = mock_real_ip(app2) + client = await aiohttp_client(app2) - set_mock_ip = mock_real_ip(app) + for remote_addr in UNTRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = await client.get('/') + assert resp.status == 401, \ + "{} shouldn't be trusted".format(remote_addr) + + for remote_addr in TRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = await client.get('/') + assert resp.status == 200, \ + "{} should be trusted".format(remote_addr) + + +async def test_auth_active_access_with_access_token_in_header( + app, aiohttp_client): + """Test access with access token in header.""" + setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) + req = await client.get( + '/', headers={'Authorization': 'Bearer {}'.format(ACCESS_TOKEN)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'AUTHORIZATION': 'Bearer {}'.format(ACCESS_TOKEN)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'authorization': 'Bearer {}'.format(ACCESS_TOKEN)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'Authorization': ACCESS_TOKEN}) + assert req.status == 401 + + req = await client.get( + '/', headers={'Authorization': 'BEARER {}'.format(ACCESS_TOKEN)}) + assert req.status == 401 + + req = await client.get( + '/', headers={'Authorization': 'Bearer wrong-pass'}) + assert req.status == 401 + + +async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): + """Test access with an untrusted ip address.""" + setup_auth(app2, TRUSTED_NETWORKS, True, api_password=None) + + set_mock_ip = mock_real_ip(app2) + client = await aiohttp_client(app2) + for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get('/') @@ -146,3 +219,43 @@ async def test_access_with_trusted_ip(aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + + +async def test_auth_active_blocked_api_password_access(app, aiohttp_client): + """Test access using api_password should be blocked when auth.active.""" + setup_auth(app, [], True, api_password=API_PASSWORD) + client = await aiohttp_client(app) + + req = await client.get( + '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert req.status == 401 + + resp = await client.get('/', params={ + 'api_password': API_PASSWORD + }) + assert resp.status == 401 + + req = await client.get( + '/', + auth=BasicAuth('homeassistant', API_PASSWORD)) + assert req.status == 401 + + +async def test_auth_legacy_support_api_password_access(app, aiohttp_client): + """Test access using api_password if auth.support_legacy.""" + setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) + client = await aiohttp_client(app) + + req = await client.get( + '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert req.status == 200 + + resp = await client.get('/', params={ + 'api_password': API_PASSWORD + }) + assert resp.status == 200 + + req = await client.get( + '/', + auth=BasicAuth('homeassistant', API_PASSWORD)) + assert req.status == 200 diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index fbd8584a7d14ae..6ea90bcdb88f24 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -77,7 +77,7 @@ def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): assert mock_process_wrong_login.called assert msg['type'] == wapi.TYPE_AUTH_INVALID - assert msg['message'] == 'Invalid password' + assert msg['message'] == 'Invalid access token or password' @asyncio.coroutine @@ -316,47 +316,103 @@ def test_unknown_command(websocket_client): assert msg['error']['code'] == wapi.ERR_UNKNOWN_COMMAND -async def test_auth_with_token(hass, aiohttp_client, hass_access_token): +async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): """Test authenticating with a token.""" assert await async_setup_component(hass, 'websocket_api', { - 'http': { - 'api_password': API_PASSWORD - } - }) + 'http': { + 'api_password': API_PASSWORD + } + }) client = await aiohttp_client(hass.http.app) async with client.ws_connect(wapi.URL) as ws: - auth_msg = await ws.receive_json() - assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': wapi.TYPE_AUTH, - 'access_token': hass_access_token.token - }) + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': hass_access_token.token + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == wapi.TYPE_AUTH_OK + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK -async def test_auth_with_invalid_token(hass, aiohttp_client): +async def test_auth_active_with_password_not_allow(hass, aiohttp_client): """Test authenticating with a token.""" assert await async_setup_component(hass, 'websocket_api', { - 'http': { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active', + return_value=True): + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, 'api_password': API_PASSWORD - } - }) + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID + + +async def test_auth_legacy_support_with_password(hass, aiohttp_client): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) client = await aiohttp_client(hass.http.app) async with client.ws_connect(wapi.URL) as ws: - auth_msg = await ws.receive_json() - assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + with patch('homeassistant.auth.AuthManager.active', + return_value=True),\ + patch('homeassistant.auth.AuthManager.support_legacy', + return_value=True): + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'api_password': API_PASSWORD + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK - await ws.send_json({ - 'type': wapi.TYPE_AUTH, - 'access_token': 'incorrect' - }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID +async def test_auth_with_invalid_token(hass, aiohttp_client): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': 'incorrect' + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID From cfe7c0aa017c316a3fe97456e6eef44e1c41afa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 1 Jul 2018 11:40:23 +0300 Subject: [PATCH 020/147] Upgrade pytest to 3.6.2 (#15241) --- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7ee0e166cf2839..d6e92d5b8ffe30 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,5 +13,5 @@ pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.0 -pytest==3.6.1 +pytest==3.6.2 requests_mock==1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bb05bbdd00ba4..bdd96562206dc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -14,7 +14,7 @@ pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.0 -pytest==3.6.1 +pytest==3.6.2 requests_mock==1.5 From c0229ebb77c0906b6f0258d6a44c37b8b07eea69 Mon Sep 17 00:00:00 2001 From: Yevgeniy <33804747+sgttrs@users.noreply.github.com> Date: Sun, 1 Jul 2018 15:54:24 +0600 Subject: [PATCH 021/147] Add precipitations to Openweathermap daily forecast mode (#15240) * Add precipitations to daily forecast mode * Remove line breaks --- homeassistant/components/weather/openweathermap.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/weather/openweathermap.py b/homeassistant/components/weather/openweathermap.py index 8354757ff33eed..65fa7c8cb0f9e8 100644 --- a/homeassistant/components/weather/openweathermap.py +++ b/homeassistant/components/weather/openweathermap.py @@ -156,6 +156,8 @@ def forecast(self): entry.get_temperature('celsius').get('day'), ATTR_FORECAST_TEMP_LOW: entry.get_temperature('celsius').get('night'), + ATTR_FORECAST_PRECIPITATION: + entry.get_rain().get('all'), ATTR_FORECAST_WIND_SPEED: entry.get_wind().get('speed'), ATTR_FORECAST_WIND_BEARING: @@ -223,12 +225,10 @@ def update_forecast(self): try: if self._mode == 'daily': fcd = self.owm.daily_forecast_at_coords( - self.latitude, self.longitude, 15 - ) + self.latitude, self.longitude, 15) else: fcd = self.owm.three_hours_forecast_at_coords( - self.latitude, self.longitude - ) + self.latitude, self.longitude) except APICallError: _LOGGER.error("Exception when calling OWM web API " "to update forecast") From 3c04b0756f5fb91ff54a9a88297fdb141d9b87ca Mon Sep 17 00:00:00 2001 From: Leonardo Brondani Schenkel Date: Sun, 1 Jul 2018 12:32:48 +0200 Subject: [PATCH 022/147] deconz: proper fix light.turn_off with transition (#15227) Previous commit d4f7dfa successfully fixed the bug in which lights would not turn off if a transition was specified, however if 'bri' is not present in the payload of the PUT request set to deCONZ, then any 'transitiontime' ends up being ignored. This commit addresses the unintended side effect by reintroducing 'bri', resulting in the following payload: { "on": false, "bri": 0, "transitiontime": ... } --- homeassistant/components/light/deconz.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index d6078490e7f960..08d7f5773f7579 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -174,6 +174,7 @@ async def async_turn_off(self, **kwargs): data = {'on': False} if ATTR_TRANSITION in kwargs: + data['bri'] = 0 data['transitiontime'] = int(kwargs[ATTR_TRANSITION]) * 10 if ATTR_FLASH in kwargs: From 4c258ce08b6e53c491f948332397c128e0e83787 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Sun, 1 Jul 2018 17:48:54 +0200 Subject: [PATCH 023/147] Revert some changes to setup.py (#15248) --- setup.cfg | 14 -------------- setup.py | 12 +++++++++++- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/setup.cfg b/setup.cfg index 2abd445bb855f4..7813cc5c0472ea 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,20 +15,6 @@ classifier = Programming Language :: Python :: 3.6 Topic :: Home Automation -[options] -packages = find: -include_package_data = true -zip_safe = false - -[options.entry_points] -console_scripts = - hass = homeassistant.__main__:main - -[options.packages.find] -exclude = - tests - tests.* - [tool:pytest] testpaths = tests norecursedirs = .git testing_config diff --git a/setup.py b/setup.py index 3833f90f2d1055..928d894c9d1a9f 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Home Assistant setup script.""" from datetime import datetime as dt -from setuptools import setup +from setuptools import setup, find_packages import homeassistant.const as hass_const @@ -29,6 +29,8 @@ 'Forum': 'https://community.home-assistant.io/', } +PACKAGES = find_packages(exclude=['tests', 'tests.*']) + REQUIRES = [ 'aiohttp==3.3.2', 'astral==1.6.1', @@ -53,7 +55,15 @@ project_urls=PROJECT_URLS, author=PROJECT_AUTHOR, author_email=PROJECT_EMAIL, + packages=PACKAGES, + include_package_data=True, + zip_safe=False, install_requires=REQUIRES, python_requires='>={}'.format(MIN_PY_VERSION), test_suite='tests', + entry_points={ + 'console_scripts': [ + 'hass = homeassistant.__main__:main' + ] + }, ) From 136cc1d44decd8bcd3324254722e780f0dce26d1 Mon Sep 17 00:00:00 2001 From: David Thulke Date: Sun, 1 Jul 2018 17:51:40 +0200 Subject: [PATCH 024/147] allow extra slot values in intents (#15246) --- homeassistant/helpers/intent.py | 3 ++- tests/components/test_snips.py | 44 +++++++++++++++++++++++++++++++++ tests/helpers/test_intent.py | 36 ++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 5aa53f17e7bba0..4357c4109ebbf1 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -137,7 +137,8 @@ def async_validate_slots(self, slots): if self._slot_schema is None: self._slot_schema = vol.Schema({ key: SLOT_SCHEMA.extend({'value': validator}) - for key, validator in self.slot_schema.items()}) + for key, validator in self.slot_schema.items()}, + extra=vol.ALLOW_EXTRA) return self._slot_schema(slots) diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index d9238336768f9d..baeda2c49a839b 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -5,6 +5,7 @@ from homeassistant.bootstrap import async_setup_component from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA import homeassistant.components.snips as snips +from homeassistant.helpers.intent import (ServiceIntentHandler, async_register) from tests.common import (async_fire_mqtt_message, async_mock_intent, async_mock_service) @@ -124,6 +125,49 @@ async def test_snips_intent(hass, mqtt_mock): assert intent.text_input == 'turn the lights green' +async def test_snips_service_intent(hass, mqtt_mock): + """Test ServiceIntentHandler via Snips.""" + hass.states.async_set('light.kitchen', 'off') + calls = async_mock_service(hass, 'light', 'turn_on') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + payload = """ + { + "input": "turn the light on", + "intent": { + "intentName": "Lights", + "probability": 0.85 + }, + "siteId": "default", + "slots": [ + { + "slotName": "name", + "value": { + "kind": "Custom", + "value": "kitchen" + } + } + ] + } + """ + + async_register(hass, ServiceIntentHandler( + "Lights", "light", 'turn_on', "Turned {} on")) + + async_fire_mqtt_message(hass, 'hermes/intent/Lights', + payload) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'light' + assert calls[0].service == 'turn_on' + assert calls[0].data['entity_id'] == 'light.kitchen' + assert 'probability' not in calls[0].data + assert 'site_id' not in calls[0].data + + async def test_snips_intent_with_duration(hass, mqtt_mock): """Test intent with Snips duration.""" result = await async_setup_component(hass, "snips", { diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index a8d37a249bc92c..707129ae531743 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1,6 +1,18 @@ """Tests for the intent helpers.""" + +import unittest +import voluptuous as vol + from homeassistant.core import State -from homeassistant.helpers import intent +from homeassistant.helpers import (intent, config_validation as cv) + + +class MockIntentHandler(intent.IntentHandler): + """Provide a mock intent handler.""" + + def __init__(self, slot_schema): + """Initialize the mock handler.""" + self.slot_schema = slot_schema def test_async_match_state(): @@ -10,3 +22,25 @@ def test_async_match_state(): state = intent.async_match_state(None, 'kitch', [state1, state2]) assert state is state1 + + +class TestIntentHandler(unittest.TestCase): + """Test the Home Assistant event helpers.""" + + def test_async_validate_slots(self): + """Test async_validate_slots of IntentHandler.""" + handler1 = MockIntentHandler({ + vol.Required('name'): cv.string, + }) + + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {}) + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {'name': 1}) + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {'name': 'kitchen'}) + handler1.async_validate_slots({'name': {'value': 'kitchen'}}) + handler1.async_validate_slots({ + 'name': {'value': 'kitchen'}, + 'probability': {'value': '0.5'} + }) From 9db8759317c846998ecc820393b82c350791d683 Mon Sep 17 00:00:00 2001 From: Andy Castille Date: Sun, 1 Jul 2018 10:54:51 -0500 Subject: [PATCH 025/147] Rachio webhooks (#15111) * Make fewer requests to the Rachio API * BREAKING: Rewrite Rachio component --- .../components/binary_sensor/rachio.py | 127 +++++++ homeassistant/components/rachio.py | 289 ++++++++++++++++ homeassistant/components/switch/rachio.py | 312 +++++++++--------- requirements_all.txt | 4 +- 4 files changed, 580 insertions(+), 152 deletions(-) create mode 100644 homeassistant/components/binary_sensor/rachio.py create mode 100644 homeassistant/components/rachio.py diff --git a/homeassistant/components/binary_sensor/rachio.py b/homeassistant/components/binary_sensor/rachio.py new file mode 100644 index 00000000000000..cc3079c6e53025 --- /dev/null +++ b/homeassistant/components/binary_sensor/rachio.py @@ -0,0 +1,127 @@ +""" +Integration with the Rachio Iro sprinkler system controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rachio/ +""" +from abc import abstractmethod +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.rachio import (DOMAIN as DOMAIN_RACHIO, + KEY_DEVICE_ID, + KEY_STATUS, + KEY_SUBTYPE, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + STATUS_OFFLINE, + STATUS_ONLINE, + SUBTYPE_OFFLINE, + SUBTYPE_ONLINE,) +from homeassistant.helpers.dispatcher import dispatcher_connect + +DEPENDENCIES = ['rachio'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Rachio binary sensors.""" + devices = [] + for controller in hass.data[DOMAIN_RACHIO].controllers: + devices.append(RachioControllerOnlineBinarySensor(hass, controller)) + + add_devices(devices) + _LOGGER.info("%d Rachio binary sensor(s) added", len(devices)) + + +class RachioControllerBinarySensor(BinarySensorDevice): + """Represent a binary sensor that reflects a Rachio state.""" + + def __init__(self, hass, controller, poll=True): + """Set up a new Rachio controller binary sensor.""" + self._controller = controller + + if poll: + self._state = self._poll_update() + else: + self._state = None + + dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, + self._handle_any_update) + + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + + @property + def is_on(self) -> bool: + """Return whether the sensor has a 'true' value.""" + return self._state + + def _handle_any_update(self, *args, **kwargs) -> None: + """Determine whether an update event applies to this device.""" + if args[0][KEY_DEVICE_ID] != self._controller.controller_id: + # For another device + return + + # For this device + self._handle_update() + + @abstractmethod + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + pass + + @abstractmethod + def _handle_update(self, *args, **kwargs) -> None: + """Handle an update to the state of this sensor.""" + pass + + +class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): + """Represent a binary sensor that reflects if the controller is online.""" + + def __init__(self, hass, controller): + """Set up a new Rachio controller online binary sensor.""" + super().__init__(hass, controller, poll=False) + self._state = self._poll_update(controller.init_data) + + @property + def name(self) -> str: + """Return the name of this sensor including the controller name.""" + return "{} online".format(self._controller.name) + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'connectivity' + + @property + def icon(self) -> str: + """Return the name of an icon for this sensor.""" + return 'mdi:wifi-strength-4' if self.is_on\ + else 'mdi:wifi-strength-off-outline' + + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + if data is None: + data = self._controller.rachio.device.get( + self._controller.controller_id)[1] + + if data[KEY_STATUS] == STATUS_ONLINE: + return True + elif data[KEY_STATUS] == STATUS_OFFLINE: + return False + else: + _LOGGER.warning('"%s" reported in unknown state "%s"', self.name, + data[KEY_STATUS]) + + def _handle_update(self, *args, **kwargs) -> None: + """Handle an update to the state of this sensor.""" + if args[0][KEY_SUBTYPE] == SUBTYPE_ONLINE: + self._state = True + elif args[0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: + self._state = False + + self.schedule_update_ha_state() diff --git a/homeassistant/components/rachio.py b/homeassistant/components/rachio.py new file mode 100644 index 00000000000000..b3b2d05e933485 --- /dev/null +++ b/homeassistant/components/rachio.py @@ -0,0 +1,289 @@ +""" +Integration with the Rachio Iro sprinkler system controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rachio/ +""" +import asyncio +import logging + +from aiohttp import web +import voluptuous as vol + +from homeassistant.auth import generate_secret +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send + +REQUIREMENTS = ['rachiopy==0.1.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'rachio' + +CONF_CUSTOM_URL = 'hass_url_override' +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_CUSTOM_URL): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +# Keys used in the API JSON +KEY_DEVICE_ID = 'deviceId' +KEY_DEVICES = 'devices' +KEY_ENABLED = 'enabled' +KEY_EXTERNAL_ID = 'externalId' +KEY_ID = 'id' +KEY_NAME = 'name' +KEY_ON = 'on' +KEY_STATUS = 'status' +KEY_SUBTYPE = 'subType' +KEY_SUMMARY = 'summary' +KEY_TYPE = 'type' +KEY_URL = 'url' +KEY_USERNAME = 'username' +KEY_ZONE_ID = 'zoneId' +KEY_ZONE_NUMBER = 'zoneNumber' +KEY_ZONES = 'zones' + +STATUS_ONLINE = 'ONLINE' +STATUS_OFFLINE = 'OFFLINE' + +# Device webhook values +TYPE_CONTROLLER_STATUS = 'DEVICE_STATUS' +SUBTYPE_OFFLINE = 'OFFLINE' +SUBTYPE_ONLINE = 'ONLINE' +SUBTYPE_OFFLINE_NOTIFICATION = 'OFFLINE_NOTIFICATION' +SUBTYPE_COLD_REBOOT = 'COLD_REBOOT' +SUBTYPE_SLEEP_MODE_ON = 'SLEEP_MODE_ON' +SUBTYPE_SLEEP_MODE_OFF = 'SLEEP_MODE_OFF' +SUBTYPE_BROWNOUT_VALVE = 'BROWNOUT_VALVE' +SUBTYPE_RAIN_SENSOR_DETECTION_ON = 'RAIN_SENSOR_DETECTION_ON' +SUBTYPE_RAIN_SENSOR_DETECTION_OFF = 'RAIN_SENSOR_DETECTION_OFF' +SUBTYPE_RAIN_DELAY_ON = 'RAIN_DELAY_ON' +SUBTYPE_RAIN_DELAY_OFF = 'RAIN_DELAY_OFF' + +# Schedule webhook values +TYPE_SCHEDULE_STATUS = 'SCHEDULE_STATUS' +SUBTYPE_SCHEDULE_STARTED = 'SCHEDULE_STARTED' +SUBTYPE_SCHEDULE_STOPPED = 'SCHEDULE_STOPPED' +SUBTYPE_SCHEDULE_COMPLETED = 'SCHEDULE_COMPLETED' +SUBTYPE_WEATHER_NO_SKIP = 'WEATHER_INTELLIGENCE_NO_SKIP' +SUBTYPE_WEATHER_SKIP = 'WEATHER_INTELLIGENCE_SKIP' +SUBTYPE_WEATHER_CLIMATE_SKIP = 'WEATHER_INTELLIGENCE_CLIMATE_SKIP' +SUBTYPE_WEATHER_FREEZE = 'WEATHER_INTELLIGENCE_FREEZE' + +# Zone webhook values +TYPE_ZONE_STATUS = 'ZONE_STATUS' +SUBTYPE_ZONE_STARTED = 'ZONE_STARTED' +SUBTYPE_ZONE_STOPPED = 'ZONE_STOPPED' +SUBTYPE_ZONE_COMPLETED = 'ZONE_COMPLETED' +SUBTYPE_ZONE_CYCLING = 'ZONE_CYCLING' +SUBTYPE_ZONE_CYCLING_COMPLETED = 'ZONE_CYCLING_COMPLETED' + +# Webhook callbacks +LISTEN_EVENT_TYPES = ['DEVICE_STATUS_EVENT', 'ZONE_STATUS_EVENT'] +WEBHOOK_CONST_ID = 'homeassistant.rachio:' +WEBHOOK_PATH = URL_API + DOMAIN +SIGNAL_RACHIO_UPDATE = DOMAIN + '_update' +SIGNAL_RACHIO_CONTROLLER_UPDATE = SIGNAL_RACHIO_UPDATE + '_controller' +SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + '_zone' +SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + '_schedule' + + +def setup(hass, config) -> bool: + """Set up the Rachio component.""" + from rachiopy import Rachio + + # Listen for incoming webhook connections + hass.http.register_view(RachioWebhookView()) + + # Configure API + api_key = config[DOMAIN].get(CONF_API_KEY) + rachio = Rachio(api_key) + + # Get the URL of this server + custom_url = config[DOMAIN].get(CONF_CUSTOM_URL) + hass_url = hass.config.api.base_url if custom_url is None else custom_url + rachio.webhook_auth = generate_secret() + rachio.webhook_url = hass_url + WEBHOOK_PATH + + # Get the API user + try: + person = RachioPerson(hass, rachio) + except AssertionError as error: + _LOGGER.error("Could not reach the Rachio API: %s", error) + return False + + # Check for Rachio controller devices + if not person.controllers: + _LOGGER.error("No Rachio devices found in account %s", + person.username) + return False + else: + _LOGGER.info("%d Rachio device(s) found", len(person.controllers)) + + # Enable component + hass.data[DOMAIN] = person + return True + + +class RachioPerson(object): + """Represent a Rachio user.""" + + def __init__(self, hass, rachio): + """Create an object from the provided API instance.""" + # Use API token to get user ID + self._hass = hass + self.rachio = rachio + + response = rachio.person.getInfo() + assert int(response[0][KEY_STATUS]) == 200, "API key error" + self._id = response[1][KEY_ID] + + # Use user ID to get user data + data = rachio.person.get(self._id) + assert int(data[0][KEY_STATUS]) == 200, "User ID error" + self.username = data[1][KEY_USERNAME] + self._controllers = [RachioIro(self._hass, self.rachio, controller) + for controller in data[1][KEY_DEVICES]] + _LOGGER.info('Using Rachio API as user "%s"', self.username) + + @property + def user_id(self) -> str: + """Get the user ID as defined by the Rachio API.""" + return self._id + + @property + def controllers(self) -> list: + """Get a list of controllers managed by this account.""" + return self._controllers + + +class RachioIro(object): + """Represent a Rachio Iro.""" + + def __init__(self, hass, rachio, data): + """Initialize a Rachio device.""" + self.hass = hass + self.rachio = rachio + self._id = data[KEY_ID] + self._name = data[KEY_NAME] + self._zones = data[KEY_ZONES] + self._init_data = data + _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id) + + # Listen for all updates + self._init_webhooks() + + def _init_webhooks(self) -> None: + """Start getting updates from the Rachio API.""" + current_webhook_id = None + + # First delete any old webhooks that may have stuck around + def _deinit_webhooks(event) -> None: + """Stop getting updates from the Rachio API.""" + webhooks = self.rachio.notification.getDeviceWebhook( + self.controller_id)[1] + for webhook in webhooks: + if webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID) or\ + webhook[KEY_ID] == current_webhook_id: + self.rachio.notification.deleteWebhook(webhook[KEY_ID]) + _deinit_webhooks(None) + + # Choose which events to listen for and get their IDs + event_types = [] + for event_type in self.rachio.notification.getWebhookEventType()[1]: + if event_type[KEY_NAME] in LISTEN_EVENT_TYPES: + event_types.append({"id": event_type[KEY_ID]}) + + # Register to listen to these events from the device + url = self.rachio.webhook_url + auth = WEBHOOK_CONST_ID + self.rachio.webhook_auth + new_webhook = self.rachio.notification.postWebhook(self.controller_id, + auth, url, + event_types) + # Save ID for deletion at shutdown + current_webhook_id = new_webhook[1][KEY_ID] + self.hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks) + + def __str__(self) -> str: + """Display the controller as a string.""" + return 'Rachio controller "{}"'.format(self.name) + + @property + def controller_id(self) -> str: + """Return the Rachio API controller ID.""" + return self._id + + @property + def name(self) -> str: + """Return the user-defined name of the controller.""" + return self._name + + @property + def current_schedule(self) -> str: + """Return the schedule that the device is running right now.""" + return self.rachio.device.getCurrentSchedule(self.controller_id)[1] + + @property + def init_data(self) -> dict: + """Return the information used to set up the controller.""" + return self._init_data + + def list_zones(self, include_disabled=False) -> list: + """Return a list of the zone dicts connected to the device.""" + # All zones + if include_disabled: + return self._zones + + # Only enabled zones + return [z for z in self._zones if z[KEY_ENABLED]] + + def get_zone(self, zone_id) -> dict or None: + """Return the zone with the given ID.""" + for zone in self.list_zones(include_disabled=True): + if zone[KEY_ID] == zone_id: + return zone + + return None + + def stop_watering(self) -> None: + """Stop watering all zones connected to this controller.""" + self.rachio.device.stopWater(self.controller_id) + _LOGGER.info("Stopped watering of all zones on %s", str(self)) + + +class RachioWebhookView(HomeAssistantView): + """Provide a page for the server to call.""" + + SIGNALS = { + TYPE_CONTROLLER_STATUS: SIGNAL_RACHIO_CONTROLLER_UPDATE, + TYPE_SCHEDULE_STATUS: SIGNAL_RACHIO_SCHEDULE_UPDATE, + TYPE_ZONE_STATUS: SIGNAL_RACHIO_ZONE_UPDATE, + } + + requires_auth = False # Handled separately + url = WEBHOOK_PATH + name = url[1:].replace('/', ':') + + # pylint: disable=no-self-use + @asyncio.coroutine + async def post(self, request) -> web.Response: + """Handle webhook calls from the server.""" + hass = request.app['hass'] + data = await request.json() + + try: + auth = data.get(KEY_EXTERNAL_ID, str()).split(':')[1] + assert auth == hass.data[DOMAIN].rachio.webhook_auth + except (AssertionError, IndexError): + return web.Response(status=web.HTTPForbidden.status_code) + + update_type = data[KEY_TYPE] + if update_type in self.SIGNALS: + async_dispatcher_send(hass, self.SIGNALS[update_type], data) + + return web.Response(status=web.HTTPNoContent.status_code) diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py index dc661c3e5bfb10..5f0ca995c90f20 100644 --- a/homeassistant/components/switch/rachio.py +++ b/homeassistant/components/switch/rachio.py @@ -4,227 +4,239 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.rachio/ """ +from abc import abstractmethod from datetime import timedelta import logging - import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.rachio import (DOMAIN as DOMAIN_RACHIO, + KEY_DEVICE_ID, + KEY_ENABLED, + KEY_ID, + KEY_NAME, + KEY_ON, + KEY_SUBTYPE, + KEY_SUMMARY, + KEY_ZONE_ID, + KEY_ZONE_NUMBER, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + SIGNAL_RACHIO_ZONE_UPDATE, + SUBTYPE_ZONE_STARTED, + SUBTYPE_ZONE_STOPPED, + SUBTYPE_ZONE_COMPLETED, + SUBTYPE_SLEEP_MODE_ON, + SUBTYPE_SLEEP_MODE_OFF) import homeassistant.helpers.config_validation as cv -import homeassistant.util as util +from homeassistant.helpers.dispatcher import dispatcher_connect -REQUIREMENTS = ['rachiopy==0.1.2'] +DEPENDENCIES = ['rachio'] _LOGGER = logging.getLogger(__name__) +# Manual run length CONF_MANUAL_RUN_MINS = 'manual_run_mins' - -DATA_RACHIO = 'rachio' - DEFAULT_MANUAL_RUN_MINS = 10 -MIN_UPDATE_INTERVAL = timedelta(seconds=30) -MIN_FORCED_UPDATE_INTERVAL = timedelta(seconds=1) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_MANUAL_RUN_MINS, default=DEFAULT_MANUAL_RUN_MINS): cv.positive_int, }) +ATTR_ZONE_SUMMARY = 'Summary' +ATTR_ZONE_NUMBER = 'Zone number' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Rachio switches.""" - from rachiopy import Rachio + manual_run_time = timedelta(minutes=config.get(CONF_MANUAL_RUN_MINS)) + _LOGGER.info("Rachio run time is %s", str(manual_run_time)) - # Get options - manual_run_mins = config.get(CONF_MANUAL_RUN_MINS) - _LOGGER.debug("Rachio run time is %d min", manual_run_mins) + # Add all zones from all controllers as switches + devices = [] + for controller in hass.data[DOMAIN_RACHIO].controllers: + devices.append(RachioStandbySwitch(hass, controller)) - access_token = config.get(CONF_ACCESS_TOKEN) + for zone in controller.list_zones(): + devices.append(RachioZone(hass, controller, zone, manual_run_time)) - # Configure API - _LOGGER.debug("Configuring Rachio API") - rachio = Rachio(access_token) + add_devices(devices) + _LOGGER.info("%d Rachio switch(es) added", len(devices)) - person = None - try: - person = _get_person(rachio) - except KeyError: - _LOGGER.error( - "Could not reach the Rachio API. Is your access token valid?") - return - # Get and persist devices - devices = _list_devices(rachio, manual_run_mins) - if not devices: - _LOGGER.error( - "No Rachio devices found in account %s", person['username']) - return +class RachioSwitch(SwitchDevice): + """Represent a Rachio state that can be toggled.""" - hass.data[DATA_RACHIO] = devices[0] + def __init__(self, controller, poll=True): + """Initialize a new Rachio switch.""" + self._controller = controller - if len(devices) > 1: - _LOGGER.warning("Multiple Rachio devices found in account, " - "using %s", hass.data[DATA_RACHIO].device_id) - else: - _LOGGER.debug("Found Rachio device") + if poll: + self._state = self._poll_update() + else: + self._state = None - hass.data[DATA_RACHIO].update() - add_devices(hass.data[DATA_RACHIO].list_zones()) + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + @property + def name(self) -> str: + """Get a name for this switch.""" + return "Switch on {}".format(self._controller.name) -def _get_person(rachio): - """Pull the account info of the person whose access token was provided.""" - person_id = rachio.person.getInfo()[1]['id'] - return rachio.person.get(person_id)[1] + @property + def is_on(self) -> bool: + """Return whether the switch is currently on.""" + return self._state + @abstractmethod + def _poll_update(self, data=None) -> bool: + """Poll the API.""" + pass -def _list_devices(rachio, manual_run_mins): - """Pull a list of devices on the account.""" - return [RachioIro(rachio, d['id'], manual_run_mins) - for d in _get_person(rachio)['devices']] + def _handle_any_update(self, *args, **kwargs) -> None: + """Determine whether an update event applies to this device.""" + if args[0][KEY_DEVICE_ID] != self._controller.controller_id: + # For another device + return + # For this device + self._handle_update(args, kwargs) -class RachioIro(object): - """Representation of a Rachio Iro.""" + @abstractmethod + def _handle_update(self, *args, **kwargs) -> None: + """Handle incoming webhook data.""" + pass - def __init__(self, rachio, device_id, manual_run_mins): - """Initialize a Rachio device.""" - self.rachio = rachio - self._device_id = device_id - self.manual_run_mins = manual_run_mins - self._device = None - self._running = None - self._zones = None - def __str__(self): - """Display the device as a string.""" - return "Rachio Iro {}".format(self.serial_number) +class RachioStandbySwitch(RachioSwitch): + """Representation of a standby status/button.""" - @property - def device_id(self): - """Return the Rachio API device ID.""" - return self._device['id'] + def __init__(self, hass, controller): + """Instantiate a new Rachio standby mode switch.""" + dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, + self._handle_any_update) + super().__init__(controller, poll=False) + self._poll_update(controller.init_data) @property - def status(self): - """Return the current status of the device.""" - return self._device['status'] + def name(self) -> str: + """Return the name of the standby switch.""" + return "{} in standby mode".format(self._controller.name) @property - def serial_number(self): - """Return the serial number of the device.""" - return self._device['serialNumber'] + def icon(self) -> str: + """Return an icon for the standby switch.""" + return "mdi:power" - @property - def is_paused(self): - """Return whether the device is temporarily disabled.""" - return self._device['paused'] + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + if data is None: + data = self._controller.rachio.device.get( + self._controller.controller_id)[1] - @property - def is_on(self): - """Return whether the device is powered on and connected.""" - return self._device['on'] + return not data[KEY_ON] - @property - def current_schedule(self): - """Return the schedule that the device is running right now.""" - return self._running + def _handle_update(self, *args, **kwargs) -> None: + """Update the state using webhook data.""" + if args[0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON: + self._state = True + elif args[0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF: + self._state = False - def list_zones(self, include_disabled=False): - """Return a list of the zones connected to the device, incl. data.""" - if not self._zones: - self._zones = [RachioZone(self.rachio, self, zone['id'], - self.manual_run_mins) - for zone in self._device['zones']] + self.schedule_update_ha_state() - if include_disabled: - return self._zones + def turn_on(self, **kwargs) -> None: + """Put the controller in standby mode.""" + self._controller.rachio.device.off(self._controller.controller_id) - self.update(no_throttle=True) - return [z for z in self._zones if z.is_enabled] + def turn_off(self, **kwargs) -> None: + """Resume controller functionality.""" + self._controller.rachio.device.on(self._controller.controller_id) - @util.Throttle(MIN_UPDATE_INTERVAL, MIN_FORCED_UPDATE_INTERVAL) - def update(self, **kwargs): - """Pull updated device info from the Rachio API.""" - self._device = self.rachio.device.get(self._device_id)[1] - self._running = self.rachio.device\ - .getCurrentSchedule(self._device_id)[1] - # Possibly update all zones - for zone in self.list_zones(include_disabled=True): - zone.update() - - _LOGGER.debug("Updated %s", str(self)) - - -class RachioZone(SwitchDevice): +class RachioZone(RachioSwitch): """Representation of one zone of sprinklers connected to the Rachio Iro.""" - def __init__(self, rachio, device, zone_id, manual_run_mins): + def __init__(self, hass, controller, data, manual_run_time): """Initialize a new Rachio Zone.""" - self.rachio = rachio - self._device = device - self._zone_id = zone_id - self._zone = None - self._manual_run_secs = manual_run_mins * 60 + self._id = data[KEY_ID] + self._zone_name = data[KEY_NAME] + self._zone_number = data[KEY_ZONE_NUMBER] + self._zone_enabled = data[KEY_ENABLED] + self._manual_run_time = manual_run_time + self._summary = str() + super().__init__(controller) + + # Listen for all zone updates + dispatcher_connect(hass, SIGNAL_RACHIO_ZONE_UPDATE, + self._handle_update) def __str__(self): """Display the zone as a string.""" - return "Rachio Zone {}".format(self.name) + return 'Rachio Zone "{}" on {}'.format(self.name, + str(self._controller)) @property - def zone_id(self): + def zone_id(self) -> str: """How the Rachio API refers to the zone.""" - return self._zone['id'] + return self._id @property - def unique_id(self): - """Return the unique string ID for the zone.""" - return '{iro}-{zone}'.format( - iro=self._device.device_id, zone=self.zone_id) - - @property - def number(self): - """Return the physical connection of the zone pump.""" - return self._zone['zoneNumber'] + def name(self) -> str: + """Return the friendly name of the zone.""" + return self._zone_name @property - def name(self): - """Return the friendly name of the zone.""" - return self._zone['name'] + def icon(self) -> str: + """Return the icon to display.""" + return "mdi:water" @property - def is_enabled(self): + def zone_is_enabled(self) -> bool: """Return whether the zone is allowed to run.""" - return self._zone['enabled'] + return self._zone_enabled @property - def is_on(self): - """Return whether the zone is currently running.""" - schedule = self._device.current_schedule - return self.zone_id == schedule.get('zoneId') + def state_attributes(self) -> dict: + """Return the optional state attributes.""" + return { + ATTR_ZONE_NUMBER: self._zone_number, + ATTR_ZONE_SUMMARY: self._summary, + } + + def turn_on(self, **kwargs) -> None: + """Start watering this zone.""" + # Stop other zones first + self.turn_off() - def update(self): - """Pull updated zone info from the Rachio API.""" - self._zone = self.rachio.zone.get(self._zone_id)[1] + # Start this zone + self._controller.rachio.zone.start(self.zone_id, + self._manual_run_time.seconds) + _LOGGER.debug("Watering %s on %s", self.name, self._controller.name) - # Possibly update device - self._device.update() + def turn_off(self, **kwargs) -> None: + """Stop watering all zones.""" + self._controller.stop_watering() - _LOGGER.debug("Updated %s", str(self)) + def _poll_update(self, data=None) -> bool: + """Poll the API to check whether the zone is running.""" + schedule = self._controller.current_schedule + return self.zone_id == schedule.get(KEY_ZONE_ID) - def turn_on(self, **kwargs): - """Start the zone.""" - # Stop other zones first - self.turn_off() + def _handle_update(self, *args, **kwargs) -> None: + """Handle incoming webhook zone data.""" + if args[0][KEY_ZONE_ID] != self.zone_id: + return + + self._summary = kwargs.get(KEY_SUMMARY, str()) - _LOGGER.info("Watering %s for %d s", self.name, self._manual_run_secs) - self.rachio.zone.start(self.zone_id, self._manual_run_secs) + if args[0][KEY_SUBTYPE] == SUBTYPE_ZONE_STARTED: + self._state = True + elif args[0][KEY_SUBTYPE] in [SUBTYPE_ZONE_STOPPED, + SUBTYPE_ZONE_COMPLETED]: + self._state = False - def turn_off(self, **kwargs): - """Stop all zones.""" - _LOGGER.info("Stopping watering of all zones") - self.rachio.device.stopWater(self._device.device_id) + self.schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 1267a7028111b7..b173dc1e56b8cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1163,8 +1163,8 @@ pyzabbix==0.7.4 # homeassistant.components.sensor.qnap qnapstats==0.2.6 -# homeassistant.components.switch.rachio -rachiopy==0.1.2 +# homeassistant.components.rachio +rachiopy==0.1.3 # homeassistant.components.climate.radiotherm radiotherm==1.3 From 6f582dcf24978a5288bbf583450070bb63b7c234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 1 Jul 2018 18:57:01 +0300 Subject: [PATCH 026/147] Lint cleanups (#15243) * Remove some unused imports * Fix a flake8 E271 --- homeassistant/components/google_assistant/http.py | 4 +--- homeassistant/components/google_assistant/smart_home.py | 8 -------- homeassistant/components/sensor/tibber.py | 2 +- homeassistant/config.py | 2 +- homeassistant/helpers/service.py | 2 -- homeassistant/helpers/translation.py | 2 -- homeassistant/loader.py | 8 +------- 7 files changed, 4 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 65079a1a26e754..05bc3cbd01c0a4 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -10,10 +10,8 @@ from aiohttp.web import Request, Response # Typing imports -# pylint: disable=unused-import from homeassistant.components.http import HomeAssistantView -from homeassistant.core import HomeAssistant, callback # NOQA -from homeassistant.helpers.entity import Entity # NOQA +from homeassistant.core import callback from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index f20d4f747cceb8..927139a483e0a8 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -3,14 +3,6 @@ from itertools import product import logging -# Typing imports -# pylint: disable=unused-import -# if False: -from aiohttp.web import Request, Response # NOQA -from typing import Dict, Tuple, Any, Optional # NOQA -from homeassistant.helpers.entity import Entity # NOQA -from homeassistant.core import HomeAssistant # NOQA -from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.util.decorator import Registry from homeassistant.core import callback diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 42568a6b9ada4b..c75c40dd929ca3 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -123,7 +123,7 @@ def unique_id(self): async def _fetch_data(self): try: await self._tibber_home.update_info() - await self._tibber_home.update_price_info() + await self._tibber_home.update_price_info() except (asyncio.TimeoutError, aiohttp.ClientError): return data = self._tibber_home.info['viewer']['home'] diff --git a/homeassistant/config.py b/homeassistant/config.py index 2906f07a307c0f..52ff0e19c598b2 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -7,7 +7,7 @@ import re import shutil # pylint: disable=unused-import -from typing import Any, List, Tuple, Optional # NOQA +from typing import Any, Tuple, Optional # noqa: F401 import voluptuous as vol from voluptuous.humanize import humanize_error diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9114a4db941cfc..7ab90b7a048910 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,7 +1,5 @@ """Service calling related helpers.""" import logging -# pylint: disable=unused-import -from typing import Optional # NOQA from os import path import voluptuous as vol diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index f1335f733466e5..81ec046f2e9966 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -1,7 +1,5 @@ """Translation string lookup helpers.""" import logging -# pylint: disable=unused-import -from typing import Optional # NOQA from os import path from homeassistant import config_entries diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e3e41e09db23d3..9e5efffdccbab1 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -16,17 +16,11 @@ import sys from types import ModuleType -# pylint: disable=unused-import -from typing import Dict, List, Optional, Sequence, Set # NOQA +from typing import Optional, Set from homeassistant.const import PLATFORM_FORMAT from homeassistant.util import OrderedSet -# Typing imports -# pylint: disable=using-constant-test,unused-import -if False: - from homeassistant.core import HomeAssistant # NOQA - PREPARED = False DEPENDENCY_BLACKLIST = set(('config',)) From 235282e335bc039c83e9cc848a74f22d697243b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 1 Jul 2018 13:00:34 -0400 Subject: [PATCH 027/147] Bump frontend to 20180701.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 84118e57c8f9be..7bad8ff727d937 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180629.1'] +REQUIREMENTS = ['home-assistant-frontend==20180701.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index b173dc1e56b8cb..b011bd6747ea19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180629.1 +home-assistant-frontend==20180701.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bdd96562206dc1..a0a51fdc20321e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180629.1 +home-assistant-frontend==20180701.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 4a4b9180d81853af6fc8ef578199389e6bea6770 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 1 Jul 2018 19:01:48 +0200 Subject: [PATCH 028/147] Upgrade sqlalchemy to 1.2.9 (#15250) --- homeassistant/components/recorder/__init__.py | 2 +- homeassistant/components/sensor/sql.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 38ba593261f3dc..43c2aa5c7b11bb 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,7 +35,7 @@ from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.8'] +REQUIREMENTS = ['sqlalchemy==1.2.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 7fefb0f450b54f..8574a7231da5b3 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.8'] +REQUIREMENTS = ['sqlalchemy==1.2.9'] CONF_COLUMN_NAME = 'column' CONF_QUERIES = 'queries' diff --git a/requirements_all.txt b/requirements_all.txt index b011bd6747ea19..6184bed0224c74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1283,7 +1283,7 @@ spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.8 +sqlalchemy==1.2.9 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0a51fdc20321e..6bfe170c67c0cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -194,7 +194,7 @@ somecomfort==0.5.2 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.8 +sqlalchemy==1.2.9 # homeassistant.components.statsd statsd==3.2.1 From 6c77c9d372e2b33d7474ac6caa234ea4f3c44641 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 1 Jul 2018 19:02:02 +0200 Subject: [PATCH 029/147] Upgrade WazeRouteCalculator to 0.6 (#15251) --- .../components/sensor/waze_travel_time.py | 20 ++++++++----------- requirements_all.txt | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index fc40d17d0afd29..0b059379c11805 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['WazeRouteCalculator==0.5'] +REQUIREMENTS = ['WazeRouteCalculator==0.6'] _LOGGER = logging.getLogger(__name__) @@ -40,6 +40,8 @@ SCAN_INTERVAL = timedelta(minutes=5) +TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone'] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, @@ -49,8 +51,6 @@ vol.Optional(CONF_EXCL_FILTER): cv.string, }) -TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone'] - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Waze travel time sensor platform.""" @@ -72,10 +72,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def _get_location_from_attributes(state): """Get the lat/long string from an states attributes.""" attr = state.attributes - return '{},{}'.format( - attr.get(ATTR_LATITUDE), - attr.get(ATTR_LONGITUDE) - ) + return '{},{}'.format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) class WazeTravelTime(Entity): @@ -186,13 +183,11 @@ def update(self): if self._origin_entity_id is not None: self._origin = self._get_location_from_entity( - self._origin_entity_id - ) + self._origin_entity_id) if self._destination_entity_id is not None: self._destination = self._get_location_from_entity( - self._destination_entity_id - ) + self._destination_entity_id) self._destination = self._resolve_zone(self._destination) self._origin = self._resolve_zone(self._origin) @@ -217,7 +212,8 @@ def update(self): self._state = { 'duration': duration, 'distance': distance, - 'route': route} + 'route': route, + } except WazeRouteCalculator.WRCError as exp: _LOGGER.error("Error on retrieving data: %s", exp) return diff --git a/requirements_all.txt b/requirements_all.txt index 6184bed0224c74..af71ea2ee54804 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -66,7 +66,7 @@ TravisPy==0.3.5 TwitterAPI==2.5.4 # homeassistant.components.sensor.waze_travel_time -WazeRouteCalculator==0.5 +WazeRouteCalculator==0.6 # homeassistant.components.notify.yessssms YesssSMS==0.1.1b3 From 0a186650bf2ac83f2ebeb9e7c44bfa718bf1303d Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 1 Jul 2018 10:04:12 -0700 Subject: [PATCH 030/147] Fix an issue when user's nest developer account don't have permission (#15237) --- homeassistant/components/binary_sensor/nest.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index 9da352e1268bff..31460c1eedca0d 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -31,12 +31,10 @@ STRUCTURE_BINARY_TYPES = { 'away': None, - # 'security_state', # pending python-nest update } STRUCTURE_BINARY_STATE_MAP = { 'away': {'away': True, 'home': False}, - 'security_state': {'deter': True, 'ok': False}, } _BINARY_TYPES_DEPRECATED = [ @@ -135,7 +133,7 @@ def update(self): value = getattr(self.device, self.variable) if self.variable in STRUCTURE_BINARY_TYPES: self._state = bool(STRUCTURE_BINARY_STATE_MAP - [self.variable][value]) + [self.variable].get(value)) else: self._state = bool(value) From dffe36761db78510e3c7ed43b00e991b5174fdca Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 1 Jul 2018 19:06:30 +0200 Subject: [PATCH 031/147] Make LIFX color/temperature attributes mutually exclusive (#15234) --- homeassistant/components/light/lifx.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 421356f07bc632..9b2c183c1d1a87 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -446,7 +446,9 @@ def brightness(self): @property def color_temp(self): """Return the color temperature.""" - kelvin = self.device.color[3] + _, sat, _, kelvin = self.device.color + if sat: + return None return color_util.color_temperature_kelvin_to_mired(kelvin) @property @@ -601,7 +603,7 @@ def hs_color(self): hue, sat, _, _ = self.device.color hue = hue / 65535 * 360 sat = sat / 65535 * 100 - return (hue, sat) + return (hue, sat) if sat else None class LIFXStrip(LIFXColor): From a64a66dd6216fa38360dcd2e0f590a7ded9a8514 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 1 Jul 2018 10:36:50 -0700 Subject: [PATCH 032/147] Only create front-end client_id once (#15214) * Only create frontend client_id once * Check user and client_id before create refresh token * Lint * Follow code review comment * Minor clenaup * Update doc string --- homeassistant/auth.py | 97 ++++++++++++------- homeassistant/components/frontend/__init__.py | 2 +- tests/common.py | 10 +- tests/components/auth/__init__.py | 2 +- tests/test_auth.py | 53 ++++++++-- 5 files changed, 117 insertions(+), 47 deletions(-) diff --git a/homeassistant/auth.py b/homeassistant/auth.py index f56e00bf31e553..a4e8ee05943b4c 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -1,23 +1,22 @@ """Provide an authentication layer for Home Assistant.""" import asyncio import binascii -from collections import OrderedDict -from datetime import datetime, timedelta -import os import importlib import logging +import os import uuid +from collections import OrderedDict +from datetime import datetime, timedelta import attr import voluptuous as vol from voluptuous.humanize import humanize_error from homeassistant import data_entry_flow, requirements -from homeassistant.core import callback from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID -from homeassistant.util.decorator import Registry +from homeassistant.core import callback from homeassistant.util import dt as dt_util - +from homeassistant.util.decorator import Registry _LOGGER = logging.getLogger(__name__) @@ -349,6 +348,16 @@ async def async_create_client(self, name, *, redirect_uris=None, return await self._store.async_create_client( name, redirect_uris, no_secret) + async def async_get_or_create_client(self, name, *, redirect_uris=None, + no_secret=False): + """Find a client, if not exists, create a new one.""" + for client in await self._store.async_get_clients(): + if client.name == name: + return client + + return await self._store.async_create_client( + name, redirect_uris, no_secret) + async def async_get_client(self, client_id): """Get a client.""" return await self._store.async_get_client(client_id) @@ -392,29 +401,36 @@ class AuthStore: def __init__(self, hass): """Initialize the auth store.""" self.hass = hass - self.users = None - self.clients = None + self._users = None + self._clients = None self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) async def credentials_for_provider(self, provider_type, provider_id): """Return credentials for specific auth provider type and id.""" - if self.users is None: + if self._users is None: await self.async_load() return [ credentials - for user in self.users.values() + for user in self._users.values() for credentials in user.credentials if (credentials.auth_provider_type == provider_type and credentials.auth_provider_id == provider_id) ] + async def async_get_users(self): + """Retrieve all users.""" + if self._users is None: + await self.async_load() + + return list(self._users.values()) + async def async_get_user(self, user_id): """Retrieve a user.""" - if self.users is None: + if self._users is None: await self.async_load() - return self.users.get(user_id) + return self._users.get(user_id) async def async_get_or_create_user(self, credentials, auth_provider): """Get or create a new user for given credentials. @@ -422,7 +438,7 @@ async def async_get_or_create_user(self, credentials, auth_provider): If link_user is passed in, the credentials will be linked to the passed in user if the credentials are new. """ - if self.users is None: + if self._users is None: await self.async_load() # New credentials, store in user @@ -430,7 +446,7 @@ async def async_get_or_create_user(self, credentials, auth_provider): info = await auth_provider.async_user_meta_for_credentials( credentials) # Make owner and activate user if it's the first user. - if self.users: + if self._users: is_owner = False is_active = False else: @@ -442,11 +458,11 @@ async def async_get_or_create_user(self, credentials, auth_provider): is_active=is_active, name=info.get('name'), ) - self.users[new_user.id] = new_user + self._users[new_user.id] = new_user await self.async_link_user(new_user, credentials) return new_user - for user in self.users.values(): + for user in self._users.values(): for creds in user.credentials: if (creds.auth_provider_type == credentials.auth_provider_type and creds.auth_provider_id == @@ -463,11 +479,19 @@ async def async_link_user(self, user, credentials): async def async_remove_user(self, user): """Remove a user.""" - self.users.pop(user.id) + self._users.pop(user.id) await self.async_save() async def async_create_refresh_token(self, user, client_id): """Create a new token for a user.""" + local_user = await self.async_get_user(user.id) + if local_user is None: + raise ValueError('Invalid user') + + local_client = await self.async_get_client(client_id) + if local_client is None: + raise ValueError('Invalid client_id') + refresh_token = RefreshToken(user, client_id) user.refresh_tokens[refresh_token.token] = refresh_token await self.async_save() @@ -475,10 +499,10 @@ async def async_create_refresh_token(self, user, client_id): async def async_get_refresh_token(self, token): """Get refresh token by token.""" - if self.users is None: + if self._users is None: await self.async_load() - for user in self.users.values(): + for user in self._users.values(): refresh_token = user.refresh_tokens.get(token) if refresh_token is not None: return refresh_token @@ -487,7 +511,7 @@ async def async_get_refresh_token(self, token): async def async_create_client(self, name, redirect_uris, no_secret): """Create a new client.""" - if self.clients is None: + if self._clients is None: await self.async_load() kwargs = { @@ -499,16 +523,23 @@ async def async_create_client(self, name, redirect_uris, no_secret): kwargs['secret'] = None client = Client(**kwargs) - self.clients[client.id] = client + self._clients[client.id] = client await self.async_save() return client + async def async_get_clients(self): + """Return all clients.""" + if self._clients is None: + await self.async_load() + + return list(self._clients.values()) + async def async_get_client(self, client_id): """Get a client.""" - if self.clients is None: + if self._clients is None: await self.async_load() - return self.clients.get(client_id) + return self._clients.get(client_id) async def async_load(self): """Load the users.""" @@ -516,12 +547,12 @@ async def async_load(self): # Make sure that we're not overriding data if 2 loads happened at the # same time - if self.users is not None: + if self._users is not None: return if data is None: - self.users = {} - self.clients = {} + self._users = {} + self._clients = {} return users = { @@ -565,8 +596,8 @@ async def async_load(self): cl_dict['id']: Client(**cl_dict) for cl_dict in data['clients'] } - self.users = users - self.clients = clients + self._users = users + self._clients = clients async def async_save(self): """Save users.""" @@ -577,7 +608,7 @@ async def async_save(self): 'is_active': user.is_active, 'name': user.name, } - for user in self.users.values() + for user in self._users.values() ] credentials = [ @@ -588,7 +619,7 @@ async def async_save(self): 'auth_provider_id': credential.auth_provider_id, 'data': credential.data, } - for user in self.users.values() + for user in self._users.values() for credential in user.credentials ] @@ -602,7 +633,7 @@ async def async_save(self): refresh_token.access_token_expiration.total_seconds(), 'token': refresh_token.token, } - for user in self.users.values() + for user in self._users.values() for refresh_token in user.refresh_tokens.values() ] @@ -613,7 +644,7 @@ async def async_save(self): 'created_at': access_token.created_at.isoformat(), 'token': access_token.token, } - for user in self.users.values() + for user in self._users.values() for refresh_token in user.refresh_tokens.values() for access_token in refresh_token.access_tokens ] @@ -625,7 +656,7 @@ async def async_save(self): 'secret': client.secret, 'redirect_uris': client.redirect_uris, } - for client in self.clients.values() + for client in self._clients.values() ] data = { diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 7bad8ff727d937..9a32626c66a0fe 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -201,7 +201,7 @@ def add_manifest_json_key(key, val): async def async_setup(hass, config): """Set up the serving of the frontend.""" if hass.auth.active: - client = await hass.auth.async_create_client( + client = await hass.auth.async_get_or_create_client( 'Home Assistant Frontend', redirect_uris=['/'], no_secret=True, diff --git a/tests/common.py b/tests/common.py index 1b8eabaa0db4bc..3a51cd3e059847 100644 --- a/tests/common.py +++ b/tests/common.py @@ -321,7 +321,7 @@ def add_to_hass(self, hass): def add_to_auth_manager(self, auth_mgr): """Test helper to add entry to hass.""" ensure_auth_manager_loaded(auth_mgr) - auth_mgr._store.users[self.id] = self + auth_mgr._store._users[self.id] = self return self @@ -329,10 +329,10 @@ def add_to_auth_manager(self, auth_mgr): def ensure_auth_manager_loaded(auth_mgr): """Ensure an auth manager is considered loaded.""" store = auth_mgr._store - if store.clients is None: - store.clients = {} - if store.users is None: - store.users = {} + if store._clients is None: + store._clients = {} + if store._users is None: + store._users = {} class MockModule(object): diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index f0b205ff5ce490..21719c12569b3b 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -34,7 +34,7 @@ async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, }) client = auth.Client('Test Client', CLIENT_ID, CLIENT_SECRET, redirect_uris=[CLIENT_REDIRECT_URI]) - hass.auth._store.clients[client.id] = client + hass.auth._store._clients[client.id] = client if setup_api: await async_setup_component(hass, 'api', {}) return await aiohttp_client(hass.http.app) diff --git a/tests/test_auth.py b/tests/test_auth.py index 4c0db71466e97d..5b545223c15a9c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -191,12 +191,13 @@ async def test_saving_loading(hass, hass_storage): await flush_store(manager._store._store) store2 = auth.AuthStore(hass) - await store2.async_load() - assert len(store2.users) == 1 - assert store2.users[user.id] == user + users = await store2.async_get_users() + assert len(users) == 1 + assert users[0] == user - assert len(store2.clients) == 1 - assert store2.clients[client.id] == client + clients = await store2.async_get_clients() + assert len(clients) == 1 + assert clients[0] == client def test_access_token_expired(): @@ -224,15 +225,18 @@ def test_access_token_expired(): async def test_cannot_retrieve_expired_access_token(hass): """Test that we cannot retrieve expired access tokens.""" manager = await auth.auth_manager_from_config(hass, []) + client = await manager.async_create_client('test') user = MockUser( id='mock-user', is_owner=False, is_active=False, name='Paulus', ).add_to_auth_manager(manager) - refresh_token = await manager.async_create_refresh_token(user, 'bla') - access_token = manager.async_create_access_token(refresh_token) + refresh_token = await manager.async_create_refresh_token(user, client.id) + assert refresh_token.user.id is user.id + assert refresh_token.client_id is client.id + access_token = manager.async_create_access_token(refresh_token) assert manager.async_get_access_token(access_token.token) is access_token with patch('homeassistant.auth.dt_util.utcnow', @@ -241,3 +245,38 @@ async def test_cannot_retrieve_expired_access_token(hass): # Even with unpatched time, it should have been removed from manager assert manager.async_get_access_token(access_token.token) is None + + +async def test_get_or_create_client(hass): + """Test that get_or_create_client works.""" + manager = await auth.auth_manager_from_config(hass, []) + + client1 = await manager.async_get_or_create_client( + 'Test Client', redirect_uris=['https://test.com/1']) + assert client1.name is 'Test Client' + + client2 = await manager.async_get_or_create_client( + 'Test Client', redirect_uris=['https://test.com/1']) + assert client2.id is client1.id + + +async def test_cannot_create_refresh_token_with_invalide_client_id(hass): + """Test that we cannot create refresh token with invalid client id.""" + manager = await auth.auth_manager_from_config(hass, []) + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(manager) + with pytest.raises(ValueError): + await manager.async_create_refresh_token(user, 'bla') + + +async def test_cannot_create_refresh_token_with_invalide_user(hass): + """Test that we cannot create refresh token with invalid client id.""" + manager = await auth.auth_manager_from_config(hass, []) + client = await manager.async_create_client('test') + user = MockUser(id='invalid-user') + with pytest.raises(ValueError): + await manager.async_create_refresh_token(user, client.id) From 86165750ff54e7b5302dfaa1175332f45fc7d6b6 Mon Sep 17 00:00:00 2001 From: Steven Conaway Date: Sun, 1 Jul 2018 22:02:09 -0700 Subject: [PATCH 033/147] Fix typo in Docker files (#15256) --- 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 0cb49fde54eb90..15504ea57aff2f 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -46,7 +46,7 @@ apt-get install -y --no-install-recommends ${PACKAGES[@]} ${PACKAGES_DEV[@]} # This is a list of scripts that install additional dependencies. If you only # need to install a package from the official debian repository, just add it # to the list above. Only create a script if you need compiling, manually -# downloading or a 3th party repository. +# downloading or a 3rd party repository. if [ "$INSTALL_TELLSTICK" == "yes" ]; then virtualization/Docker/scripts/tellstick fi From 6c77702dcc1f35c8454fb69c0f6bf65db2974d70 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 2 Jul 2018 10:57:26 +0300 Subject: [PATCH 034/147] Switch to own packaged version of pylgnetcast (#15042) ## Description: Switch to own packaged version of pylgnetcast Request to make a pypi package didn't get any response: https://github.com/wokar/pylgnetcast/issues/1 **Related issue (if applicable):** #7069 --- homeassistant/components/media_player/lg_netcast.py | 3 +-- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index 8c98844cf9358a..df1ee662124bd0 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -20,8 +20,7 @@ STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) import homeassistant.util as util -REQUIREMENTS = ['https://github.com/wokar/pylgnetcast/archive/' - 'v0.2.0.zip#pylgnetcast==0.2.0'] +REQUIREMENTS = ['pylgnetcast-homeassistant==0.2.0.dev0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index af71ea2ee54804..08985bff945e36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -432,9 +432,6 @@ httplib2==0.10.3 # homeassistant.components.sensor.gtfs https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 -# homeassistant.components.media_player.lg_netcast -https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 - # homeassistant.components.hydrawise hydrawiser==0.1.1 @@ -881,6 +878,9 @@ pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm pylast==2.3.0 +# homeassistant.components.media_player.lg_netcast +pylgnetcast-homeassistant==0.2.0.dev0 + # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv pylgtv==0.1.7 From d3df96a8de474ff4a327f47be576645542274115 Mon Sep 17 00:00:00 2001 From: Klaudiusz Staniek Date: Mon, 2 Jul 2018 02:44:36 -0700 Subject: [PATCH 035/147] Added setting cover tilt position in scene (#15255) ## Description: This feature adds possibly of setting tilt_position in scene for covers. **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): ```yaml scene: - name: Close Cover Tilt entities: cover.c_office_north: tilt_position: 0 - name: Open Cover Tilt entities: cover.c_office_north: tilt_position: 100 ``` ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** 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) If the code communicates with devices, web services, or third-party tools: - [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]). - [ ] New dependencies are only imported inside functions that use them ([example][ex-import]). - [ ] New or updated dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`. - [ ] New files were added to `.coveragerc`. If the code does not interact with devices: - [ ] Tests have been added to verify that the new code works. [ex-requir]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L14 [ex-import]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L54 --- homeassistant/helpers/state.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index f97d70514591c7..72deabaae2844d 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -27,14 +27,15 @@ ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME, ATTR_RESUME_ALL, SERVICE_RESUME_PROGRAM) from homeassistant.components.cover import ( - ATTR_POSITION) + ATTR_POSITION, ATTR_TILT_POSITION) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_OPTION, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, SERVICE_LOCK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, - SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, STATE_ALARM_ARMED_AWAY, + SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_HOME, STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, @@ -68,7 +69,8 @@ SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE], SERVICE_SEND_IR_CODE: [ATTR_IR_CODE], SERVICE_SELECT_OPTION: [ATTR_OPTION], - SERVICE_SET_COVER_POSITION: [ATTR_POSITION] + SERVICE_SET_COVER_POSITION: [ATTR_POSITION], + SERVICE_SET_COVER_TILT_POSITION: [ATTR_TILT_POSITION] } # Update this dict when new services are added to HA. From 4d93a9fd3804a496019cddbfc21b917853cbfbd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 2 Jul 2018 12:47:20 +0300 Subject: [PATCH 036/147] Pass tox posargs to pylint (#15226) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 8b034346475f54..ca82c83d0fcb8e 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ deps = -r{toxinidir}/requirements_test.txt -c{toxinidir}/homeassistant/package_constraints.txt commands = - pylint homeassistant + pylint {posargs} homeassistant [testenv:lint] basepython = {env:PYTHON3_PATH:python3} From 36f566a5295b1bbe1dd4f5d13a1565f41853c651 Mon Sep 17 00:00:00 2001 From: David Worsham Date: Mon, 2 Jul 2018 05:12:25 -0700 Subject: [PATCH 037/147] Fix Roomba exception (#15262) * Fix Roomba exception * Switch to single quotes --- homeassistant/components/vacuum/roomba.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index 44d22e03f41624..750c2c0ae0abad 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -284,7 +284,9 @@ def async_update(self): software_version = state.get('softwareVer') # Error message in plain english - error_msg = self.vacuum.error_message + error_msg = 'None' + if hasattr(self.vacuum, 'error_message'): + error_msg = self.vacuum.error_message self._battery_level = state.get('batPct') self._status = self.vacuum.current_state From dd59054003e1202c68b8aa7aa1cf5c2f930dc555 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Jul 2018 08:53:33 -0400 Subject: [PATCH 038/147] Update translations --- .../components/cast/.translations/cs.json | 15 +++++++++ .../components/cast/.translations/de.json | 14 ++++++++ .../components/cast/.translations/hu.json | 14 ++++++++ .../components/cast/.translations/it.json | 15 +++++++++ .../components/cast/.translations/lb.json | 15 +++++++++ .../components/cast/.translations/nl.json | 15 +++++++++ .../components/cast/.translations/sl.json | 15 +++++++++ .../cast/.translations/zh-Hant.json | 15 +++++++++ .../components/deconz/.translations/cs.json | 3 +- .../components/deconz/.translations/de.json | 8 ++++- .../components/deconz/.translations/lb.json | 3 +- .../components/deconz/.translations/nl.json | 7 ++++ .../components/deconz/.translations/sl.json | 3 +- .../deconz/.translations/zh-Hant.json | 3 +- .../components/hue/.translations/de.json | 2 +- .../components/hue/.translations/ru.json | 2 +- .../components/nest/.translations/cs.json | 33 +++++++++++++++++++ .../components/nest/.translations/de.json | 21 ++++++++++++ .../components/nest/.translations/hu.json | 20 +++++++++++ .../components/nest/.translations/it.json | 17 ++++++++++ .../components/nest/.translations/lb.json | 33 +++++++++++++++++++ .../components/nest/.translations/nl.json | 33 +++++++++++++++++++ .../components/nest/.translations/sl.json | 33 +++++++++++++++++++ .../nest/.translations/zh-Hant.json | 33 +++++++++++++++++++ .../components/sonos/.translations/cs.json | 15 +++++++++ .../components/sonos/.translations/de.json | 14 ++++++++ .../components/sonos/.translations/hu.json | 14 ++++++++ .../components/sonos/.translations/it.json | 15 +++++++++ .../components/sonos/.translations/lb.json | 15 +++++++++ .../components/sonos/.translations/nl.json | 15 +++++++++ .../components/sonos/.translations/sl.json | 15 +++++++++ .../sonos/.translations/zh-Hant.json | 15 +++++++++ script/translations_download | 2 +- 33 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/cast/.translations/cs.json create mode 100644 homeassistant/components/cast/.translations/de.json create mode 100644 homeassistant/components/cast/.translations/hu.json create mode 100644 homeassistant/components/cast/.translations/it.json create mode 100644 homeassistant/components/cast/.translations/lb.json create mode 100644 homeassistant/components/cast/.translations/nl.json create mode 100644 homeassistant/components/cast/.translations/sl.json create mode 100644 homeassistant/components/cast/.translations/zh-Hant.json create mode 100644 homeassistant/components/nest/.translations/cs.json create mode 100644 homeassistant/components/nest/.translations/de.json create mode 100644 homeassistant/components/nest/.translations/hu.json create mode 100644 homeassistant/components/nest/.translations/it.json create mode 100644 homeassistant/components/nest/.translations/lb.json create mode 100644 homeassistant/components/nest/.translations/nl.json create mode 100644 homeassistant/components/nest/.translations/sl.json create mode 100644 homeassistant/components/nest/.translations/zh-Hant.json create mode 100644 homeassistant/components/sonos/.translations/cs.json create mode 100644 homeassistant/components/sonos/.translations/de.json create mode 100644 homeassistant/components/sonos/.translations/hu.json create mode 100644 homeassistant/components/sonos/.translations/it.json create mode 100644 homeassistant/components/sonos/.translations/lb.json create mode 100644 homeassistant/components/sonos/.translations/nl.json create mode 100644 homeassistant/components/sonos/.translations/sl.json create mode 100644 homeassistant/components/sonos/.translations/zh-Hant.json diff --git a/homeassistant/components/cast/.translations/cs.json b/homeassistant/components/cast/.translations/cs.json new file mode 100644 index 00000000000000..82f063b365f1ec --- /dev/null +++ b/homeassistant/components/cast/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Google Cast.", + "single_instance_allowed": "Pouze jedin\u00e1 konfigurace Google Cast je nezbytn\u00e1." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/de.json b/homeassistant/components/cast/.translations/de.json new file mode 100644 index 00000000000000..2572c3344ebac2 --- /dev/null +++ b/homeassistant/components/cast/.translations/de.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Google Cast Ger\u00e4te im Netzwerk gefunden." + }, + "step": { + "confirm": { + "description": "M\u00f6chten Sie Google Cast einrichten?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/hu.json b/homeassistant/components/cast/.translations/hu.json new file mode 100644 index 00000000000000..f59a1b43ef1b1f --- /dev/null +++ b/homeassistant/components/cast/.translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Google Cast szolg\u00e1ltat\u00e1st?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/it.json b/homeassistant/components/cast/.translations/it.json new file mode 100644 index 00000000000000..21c8e60518e2ad --- /dev/null +++ b/homeassistant/components/cast/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo Google Cast trovato in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Google Cast." + }, + "step": { + "confirm": { + "description": "Vuoi configurare Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/lb.json b/homeassistant/components/cast/.translations/lb.json new file mode 100644 index 00000000000000..f1daff8306955c --- /dev/null +++ b/homeassistant/components/cast/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Google Cast Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Google Cast ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll Google Cast konfigur\u00e9iert ginn?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/nl.json b/homeassistant/components/cast/.translations/nl.json new file mode 100644 index 00000000000000..91c428770f5fc8 --- /dev/null +++ b/homeassistant/components/cast/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Google Cast-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Google Cast nodig." + }, + "step": { + "confirm": { + "description": "Wilt u Google Cast instellen?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/sl.json b/homeassistant/components/cast/.translations/sl.json new file mode 100644 index 00000000000000..24a7215574dbd9 --- /dev/null +++ b/homeassistant/components/cast/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju niso najdene naprave Google Cast.", + "single_instance_allowed": "Potrebna je samo ena konfiguracija Google Cast-a." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/zh-Hant.json b/homeassistant/components/cast/.translations/zh-Hant.json new file mode 100644 index 00000000000000..711ac3203978c6 --- /dev/null +++ b/homeassistant/components/cast/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Google Cast \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Google Cast \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Google Cast\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/cs.json b/homeassistant/components/deconz/.translations/cs.json index 0721cac3321bfc..1588766e406c78 100644 --- a/homeassistant/components/deconz/.translations/cs.json +++ b/homeassistant/components/deconz/.translations/cs.json @@ -22,7 +22,8 @@ }, "options": { "data": { - "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel" + "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel", + "allow_deconz_groups": "Povolit import skupin deCONZ " }, "title": "Dal\u0161\u00ed mo\u017enosti konfigurace pro deCONZ" } diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 9d3dc9e6e62f41..b09b7e15b31a61 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -19,8 +19,14 @@ "link": { "description": "Entsperren Sie Ihr deCONZ-Gateway, um sich bei Home Assistant zu registrieren. \n\n 1. Gehen Sie zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccken Sie die Taste \"Gateway entsperren\"", "title": "Mit deCONZ verbinden" + }, + "options": { + "data": { + "allow_clip_sensor": "Import virtueller Sensoren zulassen", + "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen" + } } }, - "title": "deCONZ" + "title": "deCONZ Zigbee Gateway" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 46190d23926b8c..3de7de9ddb3e6e 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -22,7 +22,8 @@ }, "options": { "data": { - "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren" + "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren", + "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen" }, "title": "Extra Konfiguratiouns Optiounen fir deCONZ" } diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index 90d13bb39b470d..6f3fa2ec9a4c01 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -19,6 +19,13 @@ "link": { "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen\n2. Druk op de knop \"Gateway ontgrendelen\"", "title": "Koppel met deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe", + "allow_deconz_groups": "Sta de import van deCONZ-groepen toe" + }, + "title": "Extra configuratieopties voor deCONZ" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index 59c5577c96b5a8..bc7a2cbd861583 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -22,7 +22,8 @@ }, "options": { "data": { - "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev" + "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev", + "allow_deconz_groups": "Dovoli uvoz deCONZ skupin" }, "title": "Dodatne mo\u017enosti konfiguracije za deCONZ" } diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 17cbe87f1e8f9b..5cd1a14d499bd3 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -22,7 +22,8 @@ }, "options": { "data": { - "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668" + "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44" }, "title": "deCONZ \u9644\u52a0\u8a2d\u5b9a\u9078\u9805" } diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json index d466488e9fcd85..dc0968dc88acbb 100644 --- a/homeassistant/components/hue/.translations/de.json +++ b/homeassistant/components/hue/.translations/de.json @@ -24,6 +24,6 @@ "title": "Hub verbinden" } }, - "title": "Philips Hue Bridge" + "title": "" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json index ea1e4fff1bf915..b471dd1a0cd59e 100644 --- a/homeassistant/components/hue/.translations/ru.json +++ b/homeassistant/components/hue/.translations/ru.json @@ -24,6 +24,6 @@ "title": "\u0421\u0432\u044f\u0437\u044c \u0441 \u0445\u0430\u0431\u043e\u043c" } }, - "title": "\u0428\u043b\u044e\u0437 Philips Hue" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/cs.json b/homeassistant/components/nest/.translations/cs.json new file mode 100644 index 00000000000000..c884226174b00b --- /dev/null +++ b/homeassistant/components/nest/.translations/cs.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "M\u016f\u017eete nastavit pouze jeden Nest \u00fa\u010det.", + "authorize_url_fail": "Nezn\u00e1m\u00e1 chyba p\u0159i generov\u00e1n\u00ed autoriza\u010dn\u00ed URL.", + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00ed URL vypr\u0161el", + "no_flows": "Pot\u0159ebujete nakonfigurovat Nest, abyste se s n\u00edm mohli autentizovat. [P\u0159e\u010dt\u011bte si pros\u00edm pokyny] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Intern\u00ed chyba ov\u011b\u0159en\u00ed k\u00f3du", + "invalid_code": "Neplatn\u00fd k\u00f3d", + "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el", + "unknown": "Nezn\u00e1m\u00e1 chyba ov\u011b\u0159en\u00ed k\u00f3du" + }, + "step": { + "init": { + "data": { + "flow_impl": "Poskytovatel" + }, + "description": "Zvolte pomoc\u00ed kter\u00e9ho poskytovatele ov\u011b\u0159en\u00ed chcete ov\u011b\u0159it slu\u017ebu Nest.", + "title": "Poskytovatel ov\u011b\u0159en\u00ed" + }, + "link": { + "data": { + "code": "K\u00f3d PIN" + }, + "description": "Chcete-li propojit \u00fa\u010det Nest, [autorizujte sv\u016fj \u00fa\u010det]({url}). \n\n Po autorizaci zkop\u00edrujte n\u00ed\u017ee uveden\u00fd k\u00f3d PIN.", + "title": "Propojit s Nest \u00fa\u010dtem" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/de.json b/homeassistant/components/nest/.translations/de.json new file mode 100644 index 00000000000000..721eafa807fb6d --- /dev/null +++ b/homeassistant/components/nest/.translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "init": { + "data": { + "flow_impl": "Anbieter" + }, + "description": "W\u00e4hlen Sie, \u00fcber welchen Authentifizierungsanbieter Sie sich bei Nest authentifizieren m\u00f6chten.", + "title": "Authentifizierungsanbieter" + }, + "link": { + "data": { + "code": "PIN Code" + }, + "description": "[Autorisieren Sie ihr Konto] ( {url} ), um ihren Nest-Account zu verkn\u00fcpfen.\n\n F\u00fcgen Sie anschlie\u00dfend den erhaltenen PIN Code hier ein.", + "title": "Nest-Konto verkn\u00fcpfen" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/hu.json b/homeassistant/components/nest/.translations/hu.json new file mode 100644 index 00000000000000..abf8f79599f505 --- /dev/null +++ b/homeassistant/components/nest/.translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d" + }, + "step": { + "init": { + "data": { + "flow_impl": "Szolg\u00e1ltat\u00f3" + } + }, + "link": { + "data": { + "code": "PIN-k\u00f3d" + } + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/it.json b/homeassistant/components/nest/.translations/it.json new file mode 100644 index 00000000000000..ca34179cf5b191 --- /dev/null +++ b/homeassistant/components/nest/.translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "init": { + "title": "Fornitore di autenticazione" + }, + "link": { + "data": { + "code": "Codice PIN" + }, + "description": "Per collegare l'account Nido, [autorizzare l'account]({url}).\n\nDopo l'autorizzazione, copia-incolla il codice PIN fornito di seguito.", + "title": "Collega un account Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/lb.json b/homeassistant/components/nest/.translations/lb.json new file mode 100644 index 00000000000000..197cc8206d0510 --- /dev/null +++ b/homeassistant/components/nest/.translations/lb.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen\u00a0Nest Kont\u00a0konfigur\u00e9ieren.", + "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung\u00a0beim gener\u00e9ieren\u00a0vun der Autorisatiouns\u00a0URL.", + "no_flows": "Dir musst Nest konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung\u00a0k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Interne Feeler beim valid\u00e9ieren vum Code", + "invalid_code": "Ong\u00ebltege Code", + "timeout": "Z\u00e4it Iwwerschreidung\u00a0beim valid\u00e9ieren vum Code", + "unknown": "Onbekannte Feeler beim valid\u00e9ieren vum Code" + }, + "step": { + "init": { + "data": { + "flow_impl": "Ubidder" + }, + "description": "Wielt den Authentifikatioun Ubidder deen sech mat Nest verbanne soll.", + "title": "Authentifikatioun Ubidder" + }, + "link": { + "data": { + "code": "Pin code" + }, + "description": "Fir den Nest Kont ze verbannen, [autoris\u00e9iert \u00e4ren Kont]({url}).\nKop\u00e9iert no der Autorisatioun den Pin hei \u00ebnnendr\u00ebnner", + "title": "Nest Kont verbannen" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/nl.json b/homeassistant/components/nest/.translations/nl.json new file mode 100644 index 00000000000000..756eb07189a2be --- /dev/null +++ b/homeassistant/components/nest/.translations/nl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Je kunt slechts \u00e9\u00e9n Nest-account configureren.", + "authorize_url_fail": "Onbekende fout bij het genereren van een autoriseer-URL.", + "authorize_url_timeout": "Toestemming voor het genereren van autoriseer-url.", + "no_flows": "U moet Nest configureren voordat u zich ermee kunt authenticeren. [Gelieve de instructies te lezen](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Interne foutvalidatiecode", + "invalid_code": "Ongeldige code", + "timeout": "Time-out validatie van code", + "unknown": "Onbekende foutvalidatiecode" + }, + "step": { + "init": { + "data": { + "flow_impl": "Leverancier" + }, + "description": "Kies met welke authenticatieleverancier u wilt verifi\u00ebren met Nest.", + "title": "Authenticatieleverancier" + }, + "link": { + "data": { + "code": "Pincode" + }, + "description": "Als je je Nest-account wilt koppelen, [autoriseer je account] ( {url} ). \n\nNa autorisatie, kopieer en plak de voorziene pincode hieronder.", + "title": "Koppel Nest-account" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/sl.json b/homeassistant/components/nest/.translations/sl.json new file mode 100644 index 00000000000000..d038ed4157fab0 --- /dev/null +++ b/homeassistant/components/nest/.translations/sl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Nastavite lahko samo en ra\u010dun Nest.", + "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.", + "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", + "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Nest. [Preberite navodila](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Notranja napaka pri preverjanju kode", + "invalid_code": "Neveljavna koda", + "timeout": "\u010casovna omejitev je potekla pri preverjanju kode", + "unknown": "Neznana napaka pri preverjanju kode" + }, + "step": { + "init": { + "data": { + "flow_impl": "Ponudnik" + }, + "description": "Izberite prek katerega ponudnika overjanja \u017eelite overiti Nest.", + "title": "Ponudnik za preverjanje pristnosti" + }, + "link": { + "data": { + "code": "PIN koda" + }, + "description": "\u010ce \u017eelite povezati svoj ra\u010dun Nest, [pooblastite svoj ra\u010dun]({url}). \n\n Po odobritvi kopirajte in prilepite podano kodo PIN.", + "title": "Pove\u017eite Nest ra\u010dun" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/zh-Hant.json b/homeassistant/components/nest/.translations/zh-Hant.json new file mode 100644 index 00000000000000..6b9dbdb19b1148 --- /dev/null +++ b/homeassistant/components/nest/.translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Nest \u5e33\u865f\u3002", + "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4", + "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642", + "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Nest \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/nest/\uff09\u3002" + }, + "error": { + "internal_error": "\u8a8d\u8b49\u78bc\u5167\u90e8\u932f\u8aa4", + "invalid_code": "\u8a8d\u8b49\u78bc\u7121\u6548", + "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642", + "unknown": "\u8a8d\u8b49\u78bc\u672a\u77e5\u932f\u8aa4" + }, + "step": { + "init": { + "data": { + "flow_impl": "\u8a8d\u8b49\u63d0\u4f9b\u8005" + }, + "description": "\u65bc\u8a8d\u8b49\u63d0\u4f9b\u8005\u4e2d\u6311\u9078\u6240\u8981\u9032\u884c Nest \u8a8d\u8b49\u63d0\u4f9b\u8005\u3002", + "title": "\u8a8d\u8b49\u63d0\u4f9b\u8005" + }, + "link": { + "data": { + "code": "PIN \u78bc" + }, + "description": "\u6b32\u9023\u7d50 Nest \u5e33\u865f\uff0c[\u8a8d\u8b49\u5e33\u865f]({url}).\n\n\u65bc\u8a8d\u8b49\u5f8c\uff0c\u8907\u88fd\u4e26\u8cbc\u4e0a\u4e0b\u65b9\u7684\u8a8d\u8b49\u78bc\u3002", + "title": "\u9023\u7d50 Nest \u5e33\u865f" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/cs.json b/homeassistant/components/sonos/.translations/cs.json new file mode 100644 index 00000000000000..c0b26284cdff39 --- /dev/null +++ b/homeassistant/components/sonos/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Sonos.", + "single_instance_allowed": "Je t\u0159eba jen jedna konfigurace Sonos." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/de.json b/homeassistant/components/sonos/.translations/de.json new file mode 100644 index 00000000000000..f1b76b0d155787 --- /dev/null +++ b/homeassistant/components/sonos/.translations/de.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Sonos Ger\u00e4te im Netzwerk gefunden." + }, + "step": { + "confirm": { + "description": "M\u00f6chten Sie Sonos konfigurieren?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/hu.json b/homeassistant/components/sonos/.translations/hu.json new file mode 100644 index 00000000000000..4726d57ad249a7 --- /dev/null +++ b/homeassistant/components/sonos/.translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Sonos eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Sonos-t?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/it.json b/homeassistant/components/sonos/.translations/it.json new file mode 100644 index 00000000000000..e32557f1d95566 --- /dev/null +++ b/homeassistant/components/sonos/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Non sono presenti dispositivi Sonos in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Sonos." + }, + "step": { + "confirm": { + "description": "Vuoi installare Sonos", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/lb.json b/homeassistant/components/sonos/.translations/lb.json new file mode 100644 index 00000000000000..26eaec4584d4dc --- /dev/null +++ b/homeassistant/components/sonos/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Sonos Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Sonos ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll Sonos konfigur\u00e9iert ginn?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/nl.json b/homeassistant/components/sonos/.translations/nl.json new file mode 100644 index 00000000000000..de84482cc63c45 --- /dev/null +++ b/homeassistant/components/sonos/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Sonos-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Sonos nodig." + }, + "step": { + "confirm": { + "description": "Wilt u Sonos instellen?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/sl.json b/homeassistant/components/sonos/.translations/sl.json new file mode 100644 index 00000000000000..6773465bbbfd22 --- /dev/null +++ b/homeassistant/components/sonos/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju ni najdenih naprav Sonos.", + "single_instance_allowed": "Potrebna je samo ena konfiguracija Sonosa." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/zh-Hant.json b/homeassistant/components/sonos/.translations/zh-Hant.json new file mode 100644 index 00000000000000..c6fb13c3605d30 --- /dev/null +++ b/homeassistant/components/sonos/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Sonos \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Sonos \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Sonos\uff1f", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/script/translations_download b/script/translations_download index 099e32c9d1b164..15b6a6810563d9 100755 --- a/script/translations_download +++ b/script/translations_download @@ -28,7 +28,7 @@ mkdir -p ${LOCAL_DIR} docker run \ -v ${LOCAL_DIR}:/opt/dest/locale \ - lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ + lokalise/lokalise-cli@sha256:ddf5677f58551261008342df5849731c88bcdc152ab645b133b21819aede8218 lokalise \ --token ${LOKALISE_TOKEN} \ export ${PROJECT_ID} \ --export_empty skip \ From 00c366d7ea0f292da30884fb5b3af6cba5045e45 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Jul 2018 08:56:37 -0400 Subject: [PATCH 039/147] Update frontend to 20180702.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9a32626c66a0fe..b916b794936039 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180701.0'] +REQUIREMENTS = ['home-assistant-frontend==20180702.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 08985bff945e36..52e9c6a719ffb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180701.0 +home-assistant-frontend==20180702.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bfe170c67c0cf..ea62c0bd7e4fae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180701.0 +home-assistant-frontend==20180702.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 2145ac5e4602cdd49eb1de40592db6058f49bb1d Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Mon, 2 Jul 2018 10:55:34 -0400 Subject: [PATCH 040/147] Added support for Duke Energy smart meters (#15165) * Added support for Duke Energy smart meters * Fixed hound * Added function docstring * Moved strings to constants, implemented unique_id, and cleaned up setup. * Added doc string. * Fixed review issues. * Updated pydukenergy to 0.0.6 and set update interval to 2 hours * Updated requirements_all --- .coveragerc | 1 + .../components/sensor/duke_energy.py | 84 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 88 insertions(+) create mode 100644 homeassistant/components/sensor/duke_energy.py diff --git a/.coveragerc b/.coveragerc index 90b0a7f475d8b5..a100e2c0a4958d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -612,6 +612,7 @@ omit = homeassistant/components/sensor/domain_expiry.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/duke_energy.py homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py diff --git a/homeassistant/components/sensor/duke_energy.py b/homeassistant/components/sensor/duke_energy.py new file mode 100644 index 00000000000000..458a2929d0b6b3 --- /dev/null +++ b/homeassistant/components/sensor/duke_energy.py @@ -0,0 +1,84 @@ +""" +Support for Duke Energy Gas and Electric meters. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.duke_energy/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pydukeenergy==0.0.6'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + +LAST_BILL_USAGE = "last_bills_usage" +LAST_BILL_AVERAGE_USAGE = "last_bills_average_usage" +LAST_BILL_DAYS_BILLED = "last_bills_days_billed" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup all Duke Energy meters.""" + from pydukeenergy.api import DukeEnergy, DukeEnergyException + + try: + duke = DukeEnergy(config[CONF_USERNAME], + config[CONF_PASSWORD], + update_interval=120) + except DukeEnergyException: + _LOGGER.error("Failed to setup Duke Energy") + return + + add_devices([DukeEnergyMeter(meter) for meter in duke.get_meters()]) + + +class DukeEnergyMeter(Entity): + """Representation of a Duke Energy meter.""" + + def __init__(self, meter): + """Initialize the meter.""" + self.duke_meter = meter + + @property + def name(self): + """Return the name.""" + return "duke_energy_{}".format(self.duke_meter.id) + + @property + def unique_id(self): + """Return the unique ID.""" + return self.duke_meter.id + + @property + def state(self): + """Return yesterdays usage.""" + return self.duke_meter.get_usage() + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self.duke_meter.get_unit() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + LAST_BILL_USAGE: self.duke_meter.get_total(), + LAST_BILL_AVERAGE_USAGE: self.duke_meter.get_average(), + LAST_BILL_DAYS_BILLED: self.duke_meter.get_days_billed() + } + return attributes + + def update(self): + """Update meter.""" + self.duke_meter.update() diff --git a/requirements_all.txt b/requirements_all.txt index 52e9c6a719ffb2..3f58fefb389d6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -791,6 +791,9 @@ pydispatcher==2.0.5 # homeassistant.components.android_ip_webcam pydroid-ipcam==0.8 +# homeassistant.components.sensor.duke_energy +pydukeenergy==0.0.6 + # homeassistant.components.sensor.ebox pyebox==1.1.4 From f3588a8782aa9297cfad34ff20bad81cf9c893e3 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 2 Jul 2018 15:57:52 +0100 Subject: [PATCH 041/147] Update image_processing async (#15082) * scan() -> async_job * added async_scan --- .../components/image_processing/__init__.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 29f26cc84e6c0e..480ec31da7d127 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -69,27 +69,32 @@ @bind_hass def scan(hass, entity_id=None): - """Force process an image.""" + """Force process of all cameras or given entity.""" + hass.add_job(async_scan, hass, entity_id) + + +@callback +@bind_hass +def async_scan(hass, entity_id=None): + """Force process of all cameras or given entity.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_SCAN, data) + hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SCAN, data)) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the image processing.""" component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_scan_service(service): + async def async_scan_service(service): """Service handler for scan.""" image_entities = component.async_extract_from_service(service) update_task = [entity.async_update_ha_state(True) for entity in image_entities] if update_task: - yield from asyncio.wait(update_task, loop=hass.loop) + await asyncio.wait(update_task, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SCAN, async_scan_service, @@ -124,8 +129,7 @@ def async_process_image(self, image): """ return self.hass.async_add_job(self.process_image, image) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update image and process it. This method is a coroutine. @@ -134,7 +138,7 @@ def async_update(self): image = None try: - image = yield from camera.async_get_image( + image = await camera.async_get_image( self.camera_entity, timeout=self.timeout) except HomeAssistantError as err: @@ -142,7 +146,7 @@ def async_update(self): return # process image data - yield from self.async_process_image(image.content) + await self.async_process_image(image.content) class ImageProcessingFaceEntity(ImageProcessingEntity): From 0feb4c5439daa5104f1315c5f5d6b3b5c89d309c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Jul 2018 14:43:31 -0400 Subject: [PATCH 042/147] Bump frontend to 20180702.1 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b916b794936039..25859020be46d7 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180702.0'] +REQUIREMENTS = ['home-assistant-frontend==20180702.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 3f58fefb389d6f..34e3283abb8a58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180702.0 +home-assistant-frontend==20180702.1 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea62c0bd7e4fae..a50e10a871ee15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180702.0 +home-assistant-frontend==20180702.1 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From e6390b8e413b8573fcc83f09cc97a01b9dd530b9 Mon Sep 17 00:00:00 2001 From: shker Date: Tue, 3 Jul 2018 04:33:40 +0800 Subject: [PATCH 043/147] Fix python-miio 0.4 compatibility of the xiaomi miio device tracker (#15244) --- homeassistant/components/device_tracker/xiaomi_miio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index 5d6e1453124c09..074d6a1054ee51 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -64,7 +64,7 @@ async def async_scan_devices(self): station_info = await self.hass.async_add_job(self.device.status) _LOGGER.debug("Got new station info: %s", station_info) - for device in station_info['mat']: + for device in station_info.associated_stations: devices.append(device['mac']) except DeviceException as ex: From 120111ceeefc1ab1c864b05c44d45f6f6b3fed93 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 2 Jul 2018 23:03:56 +0200 Subject: [PATCH 044/147] Upgrade keyring to 13.1.0 (#15268) --- homeassistant/scripts/keyring.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index 51d70d1f3b2cec..0ca60894f9b983 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==13.0.0', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==13.1.0', 'keyrings.alt==3.1'] def run(args): diff --git a/requirements_all.txt b/requirements_all.txt index 34e3283abb8a58..800b7e85c16acd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -473,7 +473,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==13.0.0 +keyring==13.1.0 # homeassistant.scripts.keyring keyrings.alt==3.1 From bedd2d7e41f66bbad4380fd9efd5fc351b939ea3 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 2 Jul 2018 23:14:38 +0200 Subject: [PATCH 045/147] deCONZ - new sensor attribute 'on' and new sensor GenericFlag (#15247) * New sensor attribute 'on' * New sensor GenericFlag --- homeassistant/components/binary_sensor/deconz.py | 13 ++++++++----- homeassistant/components/deconz/__init__.py | 2 +- homeassistant/components/deconz/const.py | 3 +++ homeassistant/components/sensor/deconz.py | 13 ++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index 6f59da0755ae37..0a370d754eea4d 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -5,9 +5,9 @@ https://home-assistant.io/components/binary_sensor.deconz/ """ from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.deconz import ( - CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, - DATA_DECONZ_UNSUB) +from homeassistant.components.deconz.const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -62,7 +62,8 @@ def async_update_callback(self, reason): """ if reason['state'] or \ 'reachable' in reason['attr'] or \ - 'battery' in reason['attr']: + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: self.async_schedule_update_ha_state() @property @@ -107,6 +108,8 @@ def device_state_attributes(self): attr = {} if self._sensor.battery: attr[ATTR_BATTERY_LEVEL] = self._sensor.battery + if self._sensor.on is not None: + attr[ATTR_ON] = self._sensor.on if self._sensor.type in PRESENCE and self._sensor.dark is not None: - attr['dark'] = self._sensor.dark + attr[ATTR_DARK] = self._sensor.dark return attr diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 4fa89f8cfd3b52..88174b9d61297b 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -22,7 +22,7 @@ CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DATA_DECONZ_EVENT, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) -REQUIREMENTS = ['pydeconz==39'] +REQUIREMENTS = ['pydeconz==42'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index f7aa4c7a43057d..6deee322a15e31 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -11,3 +11,6 @@ CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' + +ATTR_DARK = 'dark' +ATTR_ON = 'on' diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 0db06622ad8338..7c492fd496d263 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -4,9 +4,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/sensor.deconz/ """ -from homeassistant.components.deconz import ( - CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, - DATA_DECONZ_UNSUB) +from homeassistant.components.deconz.const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) from homeassistant.core import callback @@ -72,7 +72,8 @@ def async_update_callback(self, reason): """ if reason['state'] or \ 'reachable' in reason['attr'] or \ - 'battery' in reason['attr']: + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: self.async_schedule_update_ha_state() @property @@ -122,8 +123,10 @@ def device_state_attributes(self): attr = {} if self._sensor.battery: attr[ATTR_BATTERY_LEVEL] = self._sensor.battery + if self._sensor.on is not None: + attr[ATTR_ON] = self._sensor.on if self._sensor.type in LIGHTLEVEL and self._sensor.dark is not None: - attr['dark'] = self._sensor.dark + attr[ATTR_DARK] = self._sensor.dark if self.unit_of_measurement == 'Watts': attr[ATTR_CURRENT] = self._sensor.current attr[ATTR_VOLTAGE] = self._sensor.voltage diff --git a/requirements_all.txt b/requirements_all.txt index 800b7e85c16acd..b3bb6261cabcec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -783,7 +783,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==39 +pydeconz==42 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a50e10a871ee15..6942b16bc29f2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -133,7 +133,7 @@ py-canary==0.5.0 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==39 +pydeconz==42 # homeassistant.components.zwave pydispatcher==2.0.5 From fb65276daf96b6e844bee1ed060b2c2fc3ba0449 Mon Sep 17 00:00:00 2001 From: nielstron Date: Mon, 2 Jul 2018 23:59:04 +0200 Subject: [PATCH 046/147] Remove math.inf as bounds --- homeassistant/components/sensor/filter.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 770287228a2ed3..bc0764f3a35477 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -11,7 +11,6 @@ from functools import partial from copy import copy from datetime import timedelta -import math import voluptuous as vol @@ -55,8 +54,6 @@ DEFAULT_PRECISION = 2 DEFAULT_FILTER_RADIUS = 2.0 DEFAULT_FILTER_TIME_CONSTANT = 10 -DEFAULT_LOWER_BOUND = -math.inf -DEFAULT_UPPER_BOUND = math.inf NAME_TEMPLATE = "{} filter" ICON = 'mdi:chart-line-variant' @@ -85,10 +82,8 @@ FILTER_RANGE_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_RANGE, - vol.Optional(CONF_FILTER_LOWER_BOUND, - default=DEFAULT_LOWER_BOUND): vol.Coerce(float), - vol.Optional(CONF_FILTER_UPPER_BOUND, - default=DEFAULT_UPPER_BOUND): vol.Coerce(float), + vol.Optional(CONF_FILTER_LOWER_BOUND): vol.Coerce(float), + vol.Optional(CONF_FILTER_UPPER_BOUND): vol.Coerce(float), }) FILTER_TIME_SMA_SCHEMA = FILTER_SCHEMA.extend({ @@ -353,7 +348,7 @@ class RangeFilter(Filter): """ def __init__(self, entity, - lower_bound, upper_bound): + lower_bound=None, upper_bound=None): """Initialize Filter.""" super().__init__(FILTER_NAME_RANGE, entity=entity) self._lower_bound = lower_bound @@ -362,7 +357,7 @@ def __init__(self, entity, def _filter_state(self, new_state): """Implement the range filter.""" - if new_state.state > self._upper_bound: + if self._upper_bound and new_state.state > self._upper_bound: self._stats_internal['erasures_up'] += 1 @@ -371,7 +366,7 @@ def _filter_state(self, new_state): self._entity, new_state) new_state.state = self._upper_bound - elif new_state.state < self._lower_bound: + elif self._lower_bound and new_state.state < self._lower_bound: self._stats_internal['erasures_low'] += 1 From 31e23ebae2f227968b9d184e6bb7f5ba9797dd88 Mon Sep 17 00:00:00 2001 From: Paul Stenius Date: Mon, 2 Jul 2018 17:03:46 -0500 Subject: [PATCH 047/147] expose climate current temperature in prometeus metrics (#15232) * expose climate current temperature in prometeus metrics * import ATTR_CURRENT_TEMPERATURE from climate instead of const * remove duplicated ATTR_CURRENT_TEMPERATURE from const * fix ATTR_CURRENT_TEMPERATURE import --- homeassistant/components/prometheus.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 6f233dafe0832a..0a6c959f243d06 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -10,6 +10,7 @@ import voluptuous as vol from aiohttp import web +from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, @@ -180,6 +181,15 @@ def _handle_climate(self, state): 'Temperature in degrees Celsius') metric.labels(**self._labels(state)).set(temp) + current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if current_temp: + if unit == TEMP_FAHRENHEIT: + current_temp = fahrenheit_to_celsius(current_temp) + metric = self._metric( + 'current_temperature_c', self.prometheus_client.Gauge, + 'Current Temperature in degrees Celsius') + metric.labels(**self._labels(state)).set(current_temp) + metric = self._metric( 'climate_state', self.prometheus_client.Gauge, 'State of the thermostat (0/1)') From cd1cfd7e8ee20dccd6273983df0c881b1bb3bc50 Mon Sep 17 00:00:00 2001 From: pepeEL Date: Tue, 3 Jul 2018 08:39:42 +0200 Subject: [PATCH 048/147] New device to support option MY in somfy (#15272) New device to support option MY in somfy --- homeassistant/components/cover/tahoma.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index cf8b7dfad48c0e..824e330d6a0712 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -81,7 +81,11 @@ def stop_cover(self, **kwargs): self.apply_action('setPosition', 'secured') elif self.tahoma_device.type in \ ('rts:BlindRTSComponent', - 'io:ExteriorVenetianBlindIOComponent'): + 'io:ExteriorVenetianBlindIOComponent', + 'rts:VenetianBlindRTSComponent', + 'rts:DualCurtainRTSComponent', + 'rts:ExteriorVenetianBlindRTSComponent', + 'rts:BlindRTSComponent'): self.apply_action('my') else: self.apply_action('stopIdentify') From ed3fe1cc6f6e8c4cb3bf5a5d1d9b8a0478e3f668 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 3 Jul 2018 09:47:14 +0200 Subject: [PATCH 049/147] Add isort configuration (#15278) --- .isort.cfg | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .isort.cfg diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000000000..79a65508287448 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +multi_line_output=4 From 6420ab5535a3edc38ee2e839b9bc4d295297d647 Mon Sep 17 00:00:00 2001 From: nielstron Date: Tue, 3 Jul 2018 11:06:10 +0200 Subject: [PATCH 050/147] Remove default none from filter sensor --- homeassistant/components/sensor/filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index bc0764f3a35477..261f6e2b510a3f 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -348,7 +348,7 @@ class RangeFilter(Filter): """ def __init__(self, entity, - lower_bound=None, upper_bound=None): + lower_bound, upper_bound): """Initialize Filter.""" super().__init__(FILTER_NAME_RANGE, entity=entity) self._lower_bound = lower_bound From 232f56de6297ee1bb5c7dbcfbb587f5096720f40 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 3 Jul 2018 12:30:56 +0200 Subject: [PATCH 051/147] Add support for new API (fixes #14911) (#15279) --- homeassistant/components/sensor/fixer.py | 27 +++++++++++------------- requirements_all.txt | 2 +- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/fixer.py b/homeassistant/components/sensor/fixer.py index 3e909b7b21de55..438366ae5558e0 100644 --- a/homeassistant/components/sensor/fixer.py +++ b/homeassistant/components/sensor/fixer.py @@ -10,15 +10,14 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_BASE, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['fixerio==0.1.1'] +REQUIREMENTS = ['fixerio==1.0.0a0'] _LOGGER = logging.getLogger(__name__) -ATTR_BASE = 'Base currency' ATTR_EXCHANGE_RATE = 'Exchange rate' ATTR_TARGET = 'Target currency' @@ -33,8 +32,8 @@ SCAN_INTERVAL = timedelta(days=1) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_TARGET): cv.string, - vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -43,17 +42,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Fixer.io sensor.""" from fixerio import Fixerio, exceptions + api_key = config.get(CONF_API_KEY) name = config.get(CONF_NAME) - base = config.get(CONF_BASE) target = config.get(CONF_TARGET) try: - Fixerio(base=base, symbols=[target], secure=True).latest() + Fixerio(symbols=[target], access_key=api_key).latest() except exceptions.FixerioException: _LOGGER.error("One of the given currencies is not supported") - return False + return - data = ExchangeData(base, target) + data = ExchangeData(target, api_key) add_devices([ExchangeRateSensor(data, name, target)], True) @@ -87,10 +86,9 @@ def device_state_attributes(self): """Return the state attributes.""" if self.data.rate is not None: return { - ATTR_BASE: self.data.rate['base'], - ATTR_TARGET: self._target, - ATTR_EXCHANGE_RATE: self.data.rate['rates'][self._target], ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_EXCHANGE_RATE: self.data.rate['rates'][self._target], + ATTR_TARGET: self._target, } @property @@ -107,16 +105,15 @@ def update(self): class ExchangeData(object): """Get the latest data and update the states.""" - def __init__(self, base_currency, target_currency): + def __init__(self, target_currency, api_key): """Initialize the data object.""" from fixerio import Fixerio + self.api_key = api_key self.rate = None - self.base_currency = base_currency self.target_currency = target_currency self.exchange = Fixerio( - base=self.base_currency, symbols=[self.target_currency], - secure=True) + symbols=[self.target_currency], access_key=self.api_key) def update(self): """Get the latest data from Fixer.io.""" diff --git a/requirements_all.txt b/requirements_all.txt index b3bb6261cabcec..b6677698772544 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -338,7 +338,7 @@ fints==0.2.1 fitbit==0.3.0 # homeassistant.components.sensor.fixer -fixerio==0.1.1 +fixerio==1.0.0a0 # homeassistant.components.light.flux_led flux_led==0.21 From 184d0a99c076cadf0335d45035a111e561ae6695 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 3 Jul 2018 13:43:24 +0300 Subject: [PATCH 052/147] Switch to own packaged version of suds-passworddigest (#15261) --- homeassistant/components/camera/onvif.py | 4 +--- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 3ae47ba5dee9df..32f8e15748d7b1 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -25,9 +25,7 @@ REQUIREMENTS = ['onvif-py3==0.1.3', 'suds-py3==1.3.3.0', - 'http://github.com/tgaugry/suds-passworddigest-py3' - '/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip' - '#suds-passworddigest-py3==0.1.2a'] + 'suds-passworddigest-homeassistant==0.1.2a0.dev0'] DEPENDENCIES = ['ffmpeg'] DEFAULT_NAME = 'ONVIF Camera' DEFAULT_PORT = 5000 diff --git a/requirements_all.txt b/requirements_all.txt index b6677698772544..c170a477f0724d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -423,9 +423,6 @@ home-assistant-frontend==20180702.1 # homeassistant.components.homematicip_cloud homematicip==0.9.4 -# homeassistant.components.camera.onvif -http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a - # homeassistant.components.remember_the_milk httplib2==0.10.3 @@ -1294,6 +1291,9 @@ statsd==3.2.1 # homeassistant.components.sensor.steam_online steamodd==4.21 +# homeassistant.components.camera.onvif +suds-passworddigest-homeassistant==0.1.2a0.dev0 + # homeassistant.components.camera.onvif suds-py3==1.3.3.0 From 5ec61e4649b4dd94a80d695a34b254f2a2a0f1d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Jul 2018 11:03:23 -0400 Subject: [PATCH 053/147] Bump frontend to 20180703.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 25859020be46d7..cb5f06f12ed6c0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180702.1'] +REQUIREMENTS = ['home-assistant-frontend==20180703.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index c170a477f0724d..26a0745a37f3b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180702.1 +home-assistant-frontend==20180703.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6942b16bc29f2e..01ac2b301e11c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180702.1 +home-assistant-frontend==20180703.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 857c58c4b7d1bfe255240b6af61acb9854fc3117 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Jul 2018 13:20:42 -0400 Subject: [PATCH 054/147] Disable the calendar panel (#15282) --- homeassistant/components/calendar/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 9716e46bc032af..35566b0cbed9f6 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -41,8 +41,9 @@ async def async_setup(hass, config): hass.http.register_view(CalendarListView(component)) hass.http.register_view(CalendarEventView(component)) - await hass.components.frontend.async_register_built_in_panel( - 'calendar', 'calendar', 'hass:calendar') + # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289 + # await hass.components.frontend.async_register_built_in_panel( + # 'calendar', 'calendar', 'hass:calendar') await component.async_setup(config) return True From b2df199674112010d880506a4fa9eed08c637c80 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Jul 2018 14:51:57 -0400 Subject: [PATCH 055/147] Bump frontend to 20180703.1 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index cb5f06f12ed6c0..d74aadd332303a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180703.0'] +REQUIREMENTS = ['home-assistant-frontend==20180703.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 26a0745a37f3b7..35e740082eab71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180703.0 +home-assistant-frontend==20180703.1 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01ac2b301e11c3..1715f95b5df45c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180703.0 +home-assistant-frontend==20180703.1 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 2525fc52b35eda328bf63d13270ccb428dac9a08 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 3 Jul 2018 20:41:54 -0600 Subject: [PATCH 056/147] Update Tile platform to be async (#15073) * Updated * Updated requirements * Added expired session handling * Changes * Member-requested changes * Bump to 2.0.2 * Bumping requirements * Better exception handling and tidying * Move asyncio stuff to HASS built-ins * Revising re-initi * Hound * Hound --- .../components/device_tracker/tile.py | 162 ++++++++++-------- requirements_all.txt | 2 +- 2 files changed, 92 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index 377686b69054f3..6df9f3c9974caa 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -5,24 +5,22 @@ https://home-assistant.io/components/device_tracker.tile/ """ import logging +from datetime import timedelta import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.const import ( CONF_USERNAME, CONF_MONITORED_VARIABLES, CONF_PASSWORD) -from homeassistant.helpers.event import track_utc_time_change +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import slugify from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['pytile==1.1.0'] +REQUIREMENTS = ['pytile==2.0.2'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' -DEFAULT_ICON = 'mdi:bluetooth' DEVICE_TYPES = ['PHONE', 'TILE'] ATTR_ALTITUDE = 'altitude' @@ -34,89 +32,111 @@ CONF_SHOW_INACTIVE = 'show_inactive' +DEFAULT_ICON = 'mdi:bluetooth' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=2) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_SHOW_INACTIVE, default=False): cv.boolean, - vol.Optional(CONF_MONITORED_VARIABLES): + vol.Optional(CONF_MONITORED_VARIABLES, default=DEVICE_TYPES): vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), }) -def setup_scanner(hass, config: dict, see, discovery_info=None): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Validate the configuration and return a Tile scanner.""" - TileDeviceScanner(hass, config, see) - return True - - -class TileDeviceScanner(DeviceScanner): - """Define a device scanner for Tiles.""" - - def __init__(self, hass, config, see): + from pytile import Client + + websession = aiohttp_client.async_get_clientsession(hass) + + config_data = await hass.async_add_job( + load_json, hass.config.path(CLIENT_UUID_CONFIG_FILE)) + if config_data: + client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD], + websession, + client_uuid=config_data['client_uuid']) + else: + client = Client( + config[CONF_USERNAME], config[CONF_PASSWORD], websession) + + config_data = {'client_uuid': client.client_uuid} + config_saved = await hass.async_add_job( + save_json, hass.config.path(CLIENT_UUID_CONFIG_FILE), config_data) + if not config_saved: + _LOGGER.error('Failed to save the client UUID') + + scanner = TileScanner( + client, hass, async_see, config[CONF_MONITORED_VARIABLES], + config[CONF_SHOW_INACTIVE]) + return await scanner.async_init() + + +class TileScanner(object): + """Define an object to retrieve Tile data.""" + + def __init__(self, client, hass, async_see, types, show_inactive): """Initialize.""" - from pytile import Client - - _LOGGER.debug('Received configuration data: %s', config) + self._async_see = async_see + self._client = client + self._hass = hass + self._show_inactive = show_inactive + self._types = types - # Load the client UUID (if it exists): - config_data = load_json(hass.config.path(CLIENT_UUID_CONFIG_FILE)) - if config_data: - _LOGGER.debug('Using existing client UUID') - self._client = Client( - config[CONF_USERNAME], - config[CONF_PASSWORD], - config_data['client_uuid']) - else: - _LOGGER.debug('Generating new client UUID') - self._client = Client( - config[CONF_USERNAME], - config[CONF_PASSWORD]) + async def async_init(self): + """Further initialize connection to the Tile servers.""" + from pytile.errors import TileError - if not save_json( - hass.config.path(CLIENT_UUID_CONFIG_FILE), - {'client_uuid': self._client.client_uuid}): - _LOGGER.error("Failed to save configuration file") + try: + await self._client.async_init() + except TileError as err: + _LOGGER.error('Unable to set up Tile scanner: %s', err) + return False - _LOGGER.debug('Client UUID: %s', self._client.client_uuid) - _LOGGER.debug('User UUID: %s', self._client.user_uuid) + await self._async_update() - self._show_inactive = config.get(CONF_SHOW_INACTIVE) - self._types = config.get(CONF_MONITORED_VARIABLES) + async_track_time_interval( + self._hass, self._async_update, DEFAULT_SCAN_INTERVAL) - self.devices = {} - self.see = see + return True - track_utc_time_change( - hass, self._update_info, second=range(0, 60, 30)) + async def _async_update(self, now=None): + """Update info from Tile.""" + from pytile.errors import SessionExpiredError, TileError - self._update_info() + _LOGGER.debug('Updating Tile data') - def _update_info(self, now=None) -> None: - """Update the device info.""" - self.devices = self._client.get_tiles( - type_whitelist=self._types, show_inactive=self._show_inactive) + try: + await self._client.asayn_init() + tiles = await self._client.tiles.all( + whitelist=self._types, show_inactive=self._show_inactive) + except SessionExpiredError: + _LOGGER.info('Session expired; trying again shortly') + return + except TileError as err: + _LOGGER.error('There was an error while updating: %s', err) + return - if not self.devices: + if not tiles: _LOGGER.warning('No Tiles found') return - for dev in self.devices: - dev_id = 'tile_{0}'.format(slugify(dev['name'])) - lat = dev['tileState']['latitude'] - lon = dev['tileState']['longitude'] - - attrs = { - ATTR_ALTITUDE: dev['tileState']['altitude'], - ATTR_CONNECTION_STATE: dev['tileState']['connection_state'], - ATTR_IS_DEAD: dev['is_dead'], - ATTR_IS_LOST: dev['tileState']['is_lost'], - ATTR_RING_STATE: dev['tileState']['ring_state'], - ATTR_VOIP_STATE: dev['tileState']['voip_state'], - } - - self.see( - dev_id=dev_id, - gps=(lat, lon), - attributes=attrs, - icon=DEFAULT_ICON - ) + for tile in tiles: + await self._async_see( + dev_id='tile_{0}'.format(slugify(tile['name'])), + gps=( + tile['tileState']['latitude'], + tile['tileState']['longitude'] + ), + attributes={ + ATTR_ALTITUDE: tile['tileState']['altitude'], + ATTR_CONNECTION_STATE: + tile['tileState']['connection_state'], + ATTR_IS_DEAD: tile['is_dead'], + ATTR_IS_LOST: tile['tileState']['is_lost'], + ATTR_RING_STATE: tile['tileState']['ring_state'], + ATTR_VOIP_STATE: tile['tileState']['voip_state'], + }, + icon=DEFAULT_ICON) diff --git a/requirements_all.txt b/requirements_all.txt index 35e740082eab71..9591925ac24775 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1113,7 +1113,7 @@ pythonegardia==1.0.39 pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile -pytile==1.1.0 +pytile==2.0.2 # homeassistant.components.climate.touchline pytouchline==0.7 From 42775142f82a5aca4abd3ed08e317d22bf50d020 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Wed, 4 Jul 2018 04:50:13 +0200 Subject: [PATCH 057/147] Fix yeelight light brightness integer (#15290) --- homeassistant/components/light/yeelight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 202c6ac594d807..791de291b4803d 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -310,7 +310,7 @@ def update(self) -> None: bright = self._properties.get('bright', None) if bright: - self._brightness = 255 * (int(bright) / 100) + self._brightness = round(255 * (int(bright) / 100)) temp_in_k = self._properties.get('ct', None) if temp_in_k: From 5f7ac09a74ea25842cb74689254c99bc67267ebd Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 4 Jul 2018 06:44:47 +0100 Subject: [PATCH 058/147] Added Push Camera (#15151) * Added push camera * add camera.push * Address comments and add tests * auff auff * trip time made no sense * travis lint * Mock dependency * hound * long line * long line * better mocking * remove blank image * no more need to mock dependency * remove import * cleanup * no longer needed * unused constant * address @pvizeli review * add force_update * Revert "add force_update" This reverts commit e203785ea8232722effeec4fc70358190ec9284a. * rename parameter --- homeassistant/components/camera/push.py | 162 ++++++++++++++++++++++++ tests/components/camera/test_push.py | 63 +++++++++ 2 files changed, 225 insertions(+) create mode 100644 homeassistant/components/camera/push.py create mode 100644 tests/components/camera/test_push.py diff --git a/homeassistant/components/camera/push.py b/homeassistant/components/camera/push.py new file mode 100644 index 00000000000000..fc4b18e26e40d2 --- /dev/null +++ b/homeassistant/components/camera/push.py @@ -0,0 +1,162 @@ +""" +Camera platform that receives images through HTTP POST. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/camera.push/ +""" +import logging + +from collections import deque +from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ + STATE_IDLE, STATE_RECORDING +from homeassistant.core import callback +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.const import CONF_NAME, CONF_TIMEOUT, HTTP_BAD_REQUEST +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_point_in_utc_time +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +CONF_BUFFER_SIZE = 'buffer' +CONF_IMAGE_FIELD = 'field' + +DEFAULT_NAME = "Push Camera" + +ATTR_FILENAME = 'filename' +ATTR_LAST_TRIP = 'last_trip' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_BUFFER_SIZE, default=1): cv.positive_int, + vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Push Camera platform.""" + cameras = [PushCamera(config[CONF_NAME], + config[CONF_BUFFER_SIZE], + config[CONF_TIMEOUT])] + + hass.http.register_view(CameraPushReceiver(cameras, + config[CONF_IMAGE_FIELD])) + + async_add_devices(cameras) + + +class CameraPushReceiver(HomeAssistantView): + """Handle pushes from remote camera.""" + + url = "/api/camera_push/{entity_id}" + name = 'api:camera_push:camera_entity' + + def __init__(self, cameras, image_field): + """Initialize CameraPushReceiver with camera entity.""" + self._cameras = cameras + self._image = image_field + + async def post(self, request, entity_id): + """Accept the POST from Camera.""" + try: + (_camera,) = [camera for camera in self._cameras + if camera.entity_id == entity_id] + except ValueError: + _LOGGER.error("Unknown push camera %s", entity_id) + return self.json_message('Unknown Push Camera', + HTTP_BAD_REQUEST) + + try: + data = await request.post() + _LOGGER.debug("Received Camera push: %s", data[self._image]) + await _camera.update_image(data[self._image].file.read(), + data[self._image].filename) + except ValueError as value_error: + _LOGGER.error("Unknown value %s", value_error) + return self.json_message('Invalid POST', HTTP_BAD_REQUEST) + except KeyError as key_error: + _LOGGER.error('In your POST message %s', key_error) + return self.json_message('{} missing'.format(self._image), + HTTP_BAD_REQUEST) + + +class PushCamera(Camera): + """The representation of a Push camera.""" + + def __init__(self, name, buffer_size, timeout): + """Initialize push camera component.""" + super().__init__() + self._name = name + self._last_trip = None + self._filename = None + self._expired_listener = None + self._state = STATE_IDLE + self._timeout = timeout + self.queue = deque([], buffer_size) + self._current_image = None + + @property + def state(self): + """Current state of the camera.""" + return self._state + + async def update_image(self, image, filename): + """Update the camera image.""" + if self._state == STATE_IDLE: + self._state = STATE_RECORDING + self._last_trip = dt_util.utcnow() + self.queue.clear() + + self._filename = filename + self.queue.appendleft(image) + + @callback + def reset_state(now): + """Set state to idle after no new images for a period of time.""" + self._state = STATE_IDLE + self._expired_listener = None + _LOGGER.debug("Reset state") + self.async_schedule_update_ha_state() + + if self._expired_listener: + self._expired_listener() + + self._expired_listener = async_track_point_in_utc_time( + self.hass, reset_state, dt_util.utcnow() + self._timeout) + + self.async_schedule_update_ha_state() + + async def async_camera_image(self): + """Return a still image response.""" + if self.queue: + if self._state == STATE_IDLE: + self.queue.rotate(1) + self._current_image = self.queue[0] + + return self._current_image + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + name: value for name, value in ( + (ATTR_LAST_TRIP, self._last_trip), + (ATTR_FILENAME, self._filename), + ) if value is not None + } diff --git a/tests/components/camera/test_push.py b/tests/components/camera/test_push.py new file mode 100644 index 00000000000000..78053e540f5cd8 --- /dev/null +++ b/tests/components/camera/test_push.py @@ -0,0 +1,63 @@ +"""The tests for generic camera component.""" +import io + +from datetime import timedelta + +from homeassistant import core as ha +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from tests.components.auth import async_setup_auth + + +async def test_bad_posting(aioclient_mock, hass, aiohttp_client): + """Test that posting to wrong api endpoint fails.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'push', + 'name': 'config_test', + }}) + + client = await async_setup_auth(hass, aiohttp_client) + + # missing file + resp = await client.post('/api/camera_push/camera.config_test') + assert resp.status == 400 + + files = {'image': io.BytesIO(b'fake')} + + # wrong entity + resp = await client.post('/api/camera_push/camera.wrong', data=files) + assert resp.status == 400 + + +async def test_posting_url(aioclient_mock, hass, aiohttp_client): + """Test that posting to api endpoint works.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'push', + 'name': 'config_test', + }}) + + client = await async_setup_auth(hass, aiohttp_client) + files = {'image': io.BytesIO(b'fake')} + + # initial state + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' + + # post image + resp = await client.post('/api/camera_push/camera.config_test', data=files) + assert resp.status == 200 + + # state recording + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'recording' + + # await timeout + shifted_time = dt_util.utcnow() + timedelta(seconds=15) + hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: shifted_time}) + await hass.async_block_till_done() + + # back to initial state + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' From a6e9dc81aa38775342e8d115840e4f4c1a0142e2 Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Wed, 4 Jul 2018 01:46:01 -0400 Subject: [PATCH 059/147] Added support to HTTPS URLs on SynologyDSM (#15270) * Added support to HTTPS URLs on SynologyDSM * Bumped python-synology to 0.1.1 * Makes lint happy * Added attribution to Synology and fixed 3rd library version * Fixed requirements_all.txt * Makes SynologyDSM defaults to 5001 using SSL --- .../components/sensor/synologydsm.py | 26 ++++++++++++++----- requirements_all.txt | 2 +- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index a0198169b6d3d4..e3c3a0cf5caaed 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -12,18 +12,20 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, TEMP_CELSIUS, - CONF_MONITORED_CONDITIONS, EVENT_HOMEASSISTANT_START, CONF_DISKS) + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, + ATTR_ATTRIBUTION, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, + EVENT_HOMEASSISTANT_START, CONF_DISKS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['python-synology==0.1.0'] +REQUIREMENTS = ['python-synology==0.2.0'] _LOGGER = logging.getLogger(__name__) +CONF_ATTRIBUTION = 'Data provided by Synology' CONF_VOLUMES = 'volumes' DEFAULT_NAME = 'Synology DSM' -DEFAULT_PORT = 5000 +DEFAULT_PORT = 5001 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -74,6 +76,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=True): cv.boolean, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): @@ -95,10 +98,11 @@ def run_setup(event): port = config.get(CONF_PORT) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) + use_ssl = config.get(CONF_SSL) unit = hass.config.units.temperature_unit monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) - api = SynoApi(host, port, username, password, unit) + api = SynoApi(host, port, username, password, unit, use_ssl) sensors = [SynoNasUtilSensor( api, variable, _UTILISATION_MON_COND[variable]) @@ -128,13 +132,14 @@ def run_setup(event): class SynoApi(object): """Class to interface with Synology DSM API.""" - def __init__(self, host, port, username, password, temp_unit): + def __init__(self, host, port, username, password, temp_unit, use_ssl): """Initialize the API wrapper class.""" from SynologyDSM import SynologyDSM self.temp_unit = temp_unit try: - self._api = SynologyDSM(host, port, username, password) + self._api = SynologyDSM(host, port, username, password, + use_https=use_ssl) except: # noqa: E722 # pylint: disable=bare-except _LOGGER.error("Error setting up Synology DSM") @@ -185,6 +190,13 @@ def update(self): if self._api is not None: self._api.update() + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + class SynoNasUtilSensor(SynoNasSensor): """Representation a Synology Utilisation Sensor.""" diff --git a/requirements_all.txt b/requirements_all.txt index 9591925ac24775..0e6e6e0fc21bcf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1080,7 +1080,7 @@ python-sochain-api==0.0.2 python-songpal==0.0.7 # homeassistant.components.sensor.synologydsm -python-synology==0.1.0 +python-synology==0.2.0 # homeassistant.components.tado python-tado==0.2.3 From cb129bd207df23fe0b41be61963b54029ac0f03b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Jul 2018 11:50:08 -0400 Subject: [PATCH 060/147] Add system generated users (#15291) * Add system generated users * Fix typing --- homeassistant/auth.py | 155 ++++++++++-------- homeassistant/components/auth/__init__.py | 16 +- tests/auth_providers/test_insecure_example.py | 2 +- .../test_legacy_api_password.py | 16 +- tests/common.py | 3 +- tests/components/conftest.py | 2 +- tests/test_auth.py | 57 ++++--- 7 files changed, 149 insertions(+), 102 deletions(-) diff --git a/homeassistant/auth.py b/homeassistant/auth.py index a4e8ee05943b4c..e6760cd9096bc2 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -79,7 +79,14 @@ def name(self): async def async_credentials(self): """Return all credentials of this provider.""" - return await self.store.credentials_for_provider(self.type, self.id) + users = await self.store.async_get_users() + return [ + credentials + for user in users + for credentials in user.credentials + if (credentials.auth_provider_type == self.type and + credentials.auth_provider_id == self.id) + ] @callback def async_create_credentials(self, data): @@ -118,10 +125,11 @@ async def async_user_meta_for_credentials(self, credentials): class User: """A user.""" + name = attr.ib(type=str) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) is_owner = attr.ib(type=bool, default=False) is_active = attr.ib(type=bool, default=False) - name = attr.ib(type=str, default=None) + system_generated = attr.ib(type=bool, default=False) # List of credentials of a user. credentials = attr.ib(type=list, default=attr.Factory(list), cmp=False) @@ -300,10 +308,45 @@ async def async_get_user(self, user_id): """Retrieve a user.""" return await self._store.async_get_user(user_id) + async def async_create_system_user(self, name): + """Create a system user.""" + return await self._store.async_create_user( + name=name, + system_generated=True, + is_active=True, + ) + async def async_get_or_create_user(self, credentials): """Get or create a user.""" - return await self._store.async_get_or_create_user( - credentials, self._async_get_auth_provider(credentials)) + if not credentials.is_new: + for user in await self._store.async_get_users(): + for creds in user.credentials: + if (creds.auth_provider_type == + credentials.auth_provider_type + and creds.auth_provider_id == + credentials.auth_provider_id): + return user + + raise ValueError('Unable to find the user.') + + auth_provider = self._async_get_auth_provider(credentials) + info = await auth_provider.async_user_meta_for_credentials( + credentials) + + kwargs = { + 'credentials': credentials, + 'name': info.get('name') + } + + # Make owner and activate user if it's the first user. + if await self._store.async_get_users(): + kwargs['is_owner'] = False + kwargs['is_active'] = False + else: + kwargs['is_owner'] = True + kwargs['is_active'] = True + + return await self._store.async_create_user(**kwargs) async def async_link_user(self, user, credentials): """Link credentials to an existing user.""" @@ -313,9 +356,20 @@ async def async_remove_user(self, user): """Remove a user.""" await self._store.async_remove_user(user) - async def async_create_refresh_token(self, user, client_id): + async def async_create_refresh_token(self, user, client=None): """Create a new refresh token for a user.""" - return await self._store.async_create_refresh_token(user, client_id) + if not user.is_active: + raise ValueError('User is not active') + + if user.system_generated and client is not None: + raise ValueError( + 'System generated users cannot have refresh tokens connected ' + 'to a client.') + + if not user.system_generated and client is None: + raise ValueError('Client is required to generate a refresh token.') + + return await self._store.async_create_refresh_token(user, client) async def async_get_refresh_token(self, token): """Get refresh token by token.""" @@ -324,7 +378,7 @@ async def async_get_refresh_token(self, token): @callback def async_create_access_token(self, refresh_token): """Create a new access token.""" - access_token = AccessToken(refresh_token) + access_token = AccessToken(refresh_token=refresh_token) self._access_tokens[access_token.token] = access_token return access_token @@ -405,19 +459,6 @@ def __init__(self, hass): self._clients = None self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - async def credentials_for_provider(self, provider_type, provider_id): - """Return credentials for specific auth provider type and id.""" - if self._users is None: - await self.async_load() - - return [ - credentials - for user in self._users.values() - for credentials in user.credentials - if (credentials.auth_provider_type == provider_type and - credentials.auth_provider_id == provider_id) - ] - async def async_get_users(self): """Retrieve all users.""" if self._users is None: @@ -426,50 +467,42 @@ async def async_get_users(self): return list(self._users.values()) async def async_get_user(self, user_id): - """Retrieve a user.""" + """Retrieve a user by id.""" if self._users is None: await self.async_load() return self._users.get(user_id) - async def async_get_or_create_user(self, credentials, auth_provider): - """Get or create a new user for given credentials. - - If link_user is passed in, the credentials will be linked to the passed - in user if the credentials are new. - """ + async def async_create_user(self, name, is_owner=None, is_active=None, + system_generated=None, credentials=None): + """Create a new user.""" if self._users is None: await self.async_load() - # New credentials, store in user - if credentials.is_new: - info = await auth_provider.async_user_meta_for_credentials( - credentials) - # Make owner and activate user if it's the first user. - if self._users: - is_owner = False - is_active = False - else: - is_owner = True - is_active = True - - new_user = User( - is_owner=is_owner, - is_active=is_active, - name=info.get('name'), - ) - self._users[new_user.id] = new_user - await self.async_link_user(new_user, credentials) - return new_user + kwargs = { + 'name': name + } - for user in self._users.values(): - for creds in user.credentials: - if (creds.auth_provider_type == credentials.auth_provider_type - and creds.auth_provider_id == - credentials.auth_provider_id): - return user + if is_owner is not None: + kwargs['is_owner'] = is_owner + + if is_active is not None: + kwargs['is_active'] = is_active + + if system_generated is not None: + kwargs['system_generated'] = system_generated + + new_user = User(**kwargs) - raise ValueError('We got credentials with ID but found no user') + self._users[new_user.id] = new_user + + if credentials is None: + await self.async_save() + return new_user + + # Saving is done inside the link. + await self.async_link_user(new_user, credentials) + return new_user async def async_link_user(self, user, credentials): """Add credentials to an existing user.""" @@ -482,17 +515,10 @@ async def async_remove_user(self, user): self._users.pop(user.id) await self.async_save() - async def async_create_refresh_token(self, user, client_id): + async def async_create_refresh_token(self, user, client=None): """Create a new token for a user.""" - local_user = await self.async_get_user(user.id) - if local_user is None: - raise ValueError('Invalid user') - - local_client = await self.async_get_client(client_id) - if local_client is None: - raise ValueError('Invalid client_id') - - refresh_token = RefreshToken(user, client_id) + client_id = client.id if client is not None else None + refresh_token = RefreshToken(user=user, client_id=client_id) user.refresh_tokens[refresh_token.token] = refresh_token await self.async_save() return refresh_token @@ -607,6 +633,7 @@ async def async_save(self): 'is_owner': user.is_owner, 'is_active': user.is_active, 'name': user.name, + 'system_generated': user.system_generated, } for user in self._users.values() ] diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 0f7295a41e0938..511999c52abaff 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -236,18 +236,16 @@ async def post(self, request, client): grant_type = data.get('grant_type') if grant_type == 'authorization_code': - return await self._async_handle_auth_code( - hass, client.id, data) + return await self._async_handle_auth_code(hass, client, data) elif grant_type == 'refresh_token': - return await self._async_handle_refresh_token( - hass, client.id, data) + return await self._async_handle_refresh_token(hass, client, data) return self.json({ 'error': 'unsupported_grant_type', }, status_code=400) - async def _async_handle_auth_code(self, hass, client_id, data): + async def _async_handle_auth_code(self, hass, client, data): """Handle authorization code request.""" code = data.get('code') @@ -256,7 +254,7 @@ async def _async_handle_auth_code(self, hass, client_id, data): 'error': 'invalid_request', }, status_code=400) - credentials = self._retrieve_credentials(client_id, code) + credentials = self._retrieve_credentials(client.id, code) if credentials is None: return self.json({ @@ -265,7 +263,7 @@ async def _async_handle_auth_code(self, hass, client_id, data): user = await hass.auth.async_get_or_create_user(credentials) refresh_token = await hass.auth.async_create_refresh_token(user, - client_id) + client) access_token = hass.auth.async_create_access_token(refresh_token) return self.json({ @@ -276,7 +274,7 @@ async def _async_handle_auth_code(self, hass, client_id, data): int(refresh_token.access_token_expiration.total_seconds()), }) - async def _async_handle_refresh_token(self, hass, client_id, data): + async def _async_handle_refresh_token(self, hass, client, data): """Handle authorization code request.""" token = data.get('refresh_token') @@ -287,7 +285,7 @@ async def _async_handle_refresh_token(self, hass, client_id, data): refresh_token = await hass.auth.async_get_refresh_token(token) - if refresh_token is None or refresh_token.client_id != client_id: + if refresh_token is None or refresh_token.client_id != client.id: return self.json({ 'error': 'invalid_grant', }, status_code=400) diff --git a/tests/auth_providers/test_insecure_example.py b/tests/auth_providers/test_insecure_example.py index 3377a60c45b010..cb0bab4afed04f 100644 --- a/tests/auth_providers/test_insecure_example.py +++ b/tests/auth_providers/test_insecure_example.py @@ -54,7 +54,7 @@ async def test_match_existing_credentials(store, provider): }, is_new=False, ) - store.credentials_for_provider = Mock(return_value=mock_coro([existing])) + provider.async_credentials = Mock(return_value=mock_coro([existing])) credentials = await provider.async_get_or_create_credentials({ 'username': 'user-test', 'password': 'password-test', diff --git a/tests/auth_providers/test_legacy_api_password.py b/tests/auth_providers/test_legacy_api_password.py index 7a8f17894aa1fc..3a186a0454c6db 100644 --- a/tests/auth_providers/test_legacy_api_password.py +++ b/tests/auth_providers/test_legacy_api_password.py @@ -21,6 +21,14 @@ def provider(hass, store): }) +@pytest.fixture +def manager(hass, store, provider): + """Mock manager.""" + return auth.AuthManager(hass, store, { + (provider.type, provider.id): provider + }) + + async def test_create_new_credential(provider): """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials({}) @@ -28,13 +36,13 @@ async def test_create_new_credential(provider): assert credentials.is_new is True -async def test_only_one_credentials(store, provider): +async def test_only_one_credentials(manager, provider): """Call create twice will return same credential.""" credentials = await provider.async_get_or_create_credentials({}) - await store.async_get_or_create_user(credentials, provider) + await manager.async_get_or_create_user(credentials) credentials2 = await provider.async_get_or_create_credentials({}) - assert credentials2.data["username"] is legacy_api_password.LEGACY_USER - assert credentials2.id is credentials.id + assert credentials2.data["username"] == legacy_api_password.LEGACY_USER + assert credentials2.id == credentials.id assert credentials2.is_new is False diff --git a/tests/common.py b/tests/common.py index 3a51cd3e059847..ccb8f49ea97923 100644 --- a/tests/common.py +++ b/tests/common.py @@ -312,7 +312,8 @@ class MockUser(auth.User): def __init__(self, id='mock-id', is_owner=True, is_active=True, name='Mock User'): """Initialize mock user.""" - super().__init__(id, is_owner, is_active, name) + super().__init__( + id=id, is_owner=is_owner, is_active=is_active, name=name) def add_to_hass(self, hass): """Test helper to add entry to hass.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 8a1b934ab76617..00e3ee88d1660b 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -34,5 +34,5 @@ def hass_access_token(hass): no_secret=True, )) refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(user, client.id)) + hass.auth.async_create_refresh_token(user, client)) yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/test_auth.py b/tests/test_auth.py index 5b545223c15a9c..8096a081679232 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -184,7 +184,7 @@ async def test_saving_loading(hass, hass_storage): client = await manager.async_create_client( 'test', redirect_uris=['https://example.com']) - refresh_token = await manager.async_create_refresh_token(user, client.id) + refresh_token = await manager.async_create_refresh_token(user, client) manager.async_create_access_token(refresh_token) @@ -226,13 +226,8 @@ async def test_cannot_retrieve_expired_access_token(hass): """Test that we cannot retrieve expired access tokens.""" manager = await auth.auth_manager_from_config(hass, []) client = await manager.async_create_client('test') - user = MockUser( - id='mock-user', - is_owner=False, - is_active=False, - name='Paulus', - ).add_to_auth_manager(manager) - refresh_token = await manager.async_create_refresh_token(user, client.id) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token(user, client) assert refresh_token.user.id is user.id assert refresh_token.client_id is client.id @@ -260,23 +255,41 @@ async def test_get_or_create_client(hass): assert client2.id is client1.id -async def test_cannot_create_refresh_token_with_invalide_client_id(hass): - """Test that we cannot create refresh token with invalid client id.""" +async def test_generating_system_user(hass): + """Test that we can add a system user.""" manager = await auth.auth_manager_from_config(hass, []) - user = MockUser( - id='mock-user', - is_owner=False, - is_active=False, - name='Paulus', - ).add_to_auth_manager(manager) + user = await manager.async_create_system_user('Hass.io') + token = await manager.async_create_refresh_token(user) + assert user.system_generated + assert token is not None + assert token.client_id is None + + +async def test_refresh_token_requires_client_for_user(hass): + """Test that we can add a system user.""" + manager = await auth.auth_manager_from_config(hass, []) + user = MockUser().add_to_auth_manager(manager) + assert user.system_generated is False + with pytest.raises(ValueError): - await manager.async_create_refresh_token(user, 'bla') + await manager.async_create_refresh_token(user) + + client = await manager.async_get_or_create_client('Test client') + token = await manager.async_create_refresh_token(user, client) + assert token is not None + assert token.client_id == client.id -async def test_cannot_create_refresh_token_with_invalide_user(hass): - """Test that we cannot create refresh token with invalid client id.""" +async def test_refresh_token_not_requires_client_for_system_user(hass): + """Test that we can add a system user.""" manager = await auth.auth_manager_from_config(hass, []) - client = await manager.async_create_client('test') - user = MockUser(id='invalid-user') + user = await manager.async_create_system_user('Hass.io') + assert user.system_generated is True + client = await manager.async_get_or_create_client('Test client') + with pytest.raises(ValueError): - await manager.async_create_refresh_token(user, client.id) + await manager.async_create_refresh_token(user, client) + + token = await manager.async_create_refresh_token(user) + assert token is not None + assert token.client_id is None From 91d6d0df84c27136556e2ab5b460bfa2c931d0b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Jul 2018 12:11:18 -0400 Subject: [PATCH 061/147] Bump frontend to 20180704.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index d74aadd332303a..0b9c8edd4117b5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180703.1'] +REQUIREMENTS = ['home-assistant-frontend==20180704.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 0e6e6e0fc21bcf..b1863df1e4658e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180703.1 +home-assistant-frontend==20180704.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1715f95b5df45c..476b3d96c3d2e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180703.1 +home-assistant-frontend==20180704.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From f65c3940aee624b27b5b60f1ded39367443d1f6f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 4 Jul 2018 17:30:15 -0600 Subject: [PATCH 062/147] Fix exception when parts of Pollen.com can't be reached (#15296) Fix exception when parts of Pollen.com can't be reached --- homeassistant/components/sensor/pollen.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 838358fcfca8f0..c11c83ab40e1c8 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -183,9 +183,12 @@ async def async_update(self): return if self._category: - data = self.pollencom.data[self._category]['Location'] + data = self.pollencom.data[self._category].get('Location') else: - data = self.pollencom.data[self._type]['Location'] + data = self.pollencom.data[self._type].get('Location') + + if not data: + return indices = [p['Index'] for p in data['periods']] average = round(mean(indices), 1) From 0f1bcfd63b81b86cb422f1ed374a49842baa9db0 Mon Sep 17 00:00:00 2001 From: Luke Fritz Date: Fri, 6 Jul 2018 03:26:03 -0500 Subject: [PATCH 063/147] Add additional sensors for Arlo Baby camera (#15074) * Add additional sensors for Arlo Baby camera * Fix linter errors * Fix linter error * Add tests for Arlo sensors * Fix linter errors * Bump pyarlo dependency to 0.1.9 * Remove unnecessary AttributeError except * Fix module reference error in py35 * Fix test * Address PR review concerns * Convert to standalone pytest methods * Fix linter errors * Fix linter errors * Fix linter errors * Fix test * Remove redundant check, fix async test * Fix linter error * Added check for total_cameras sensor, added additional attribute tests * Add missing docstring --- homeassistant/components/arlo.py | 2 +- homeassistant/components/sensor/arlo.py | 59 +++++- requirements_all.txt | 2 +- tests/components/sensor/test_arlo.py | 240 ++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 tests/components/sensor/test_arlo.py diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index fa58c9b0baa897..475e43e55a4023 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -16,7 +16,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.dispatcher import dispatcher_send -REQUIREMENTS = ['pyarlo==0.1.8'] +REQUIREMENTS = ['pyarlo==0.1.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py index 18029691dc7bae..609887e9690256 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -13,7 +13,10 @@ from homeassistant.components.arlo import ( CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS) +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) + from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -28,7 +31,10 @@ 'total_cameras': ['Arlo Cameras', None, 'video'], 'captured_today': ['Captured Today', None, 'file-video'], 'battery_level': ['Battery Level', '%', 'battery-50'], - 'signal_strength': ['Signal Strength', None, 'signal'] + 'signal_strength': ['Signal Strength', None, 'signal'], + 'temperature': ['Temperature', TEMP_CELSIUS, 'thermometer'], + 'humidity': ['Humidity', '%', 'water-percent'], + 'air_quality': ['Air Quality', 'ppm', 'biohazard'] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -41,7 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Arlo IP sensor.""" arlo = hass.data.get(DATA_ARLO) if not arlo: - return False + return sensors = [] for sensor_type in config.get(CONF_MONITORED_CONDITIONS): @@ -50,10 +56,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) else: for camera in arlo.cameras: + if sensor_type == 'temperature' or \ + sensor_type == 'humidity' or \ + sensor_type == 'air_quality': + continue + name = '{0} {1}'.format( SENSOR_TYPES[sensor_type][0], camera.name) sensors.append(ArloSensor(name, camera, sensor_type)) + for base_station in arlo.base_stations: + if ((sensor_type == 'temperature' or + sensor_type == 'humidity' or + sensor_type == 'air_quality') and + base_station.model_id == 'ABC1000'): + name = '{0} {1}'.format( + SENSOR_TYPES[sensor_type][0], base_station.name) + sensors.append(ArloSensor(name, base_station, sensor_type)) + add_devices(sensors, True) @@ -62,6 +82,7 @@ class ArloSensor(Entity): def __init__(self, name, device, sensor_type): """Initialize an Arlo sensor.""" + _LOGGER.debug('ArloSensor created for %s', name) self._name = name self._data = device self._sensor_type = sensor_type @@ -101,6 +122,15 @@ def unit_of_measurement(self): """Return the units of measurement.""" return SENSOR_TYPES.get(self._sensor_type)[1] + @property + def device_class(self): + """Return the device class of the sensor.""" + if self._sensor_type == 'temperature': + return DEVICE_CLASS_TEMPERATURE + elif self._sensor_type == 'humidity': + return DEVICE_CLASS_HUMIDITY + return None + def update(self): """Get the latest data and updates the state.""" _LOGGER.debug("Updating Arlo sensor %s", self.name) @@ -133,6 +163,24 @@ def update(self): except TypeError: self._state = None + elif self._sensor_type == 'temperature': + try: + self._state = self._data.ambient_temperature + except TypeError: + self._state = None + + elif self._sensor_type == 'humidity': + try: + self._state = self._data.ambient_humidity + except TypeError: + self._state = None + + elif self._sensor_type == 'air_quality': + try: + self._state = self._data.ambient_air_quality + except TypeError: + self._state = None + @property def device_state_attributes(self): """Return the device state attributes.""" @@ -141,10 +189,7 @@ def device_state_attributes(self): attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION attrs['brand'] = DEFAULT_BRAND - if self._sensor_type == 'last_capture' or \ - self._sensor_type == 'captured_today' or \ - self._sensor_type == 'battery_level' or \ - self._sensor_type == 'signal_strength': + if self._sensor_type != 'total_cameras': attrs['model'] = self._data.model_id return attrs diff --git a/requirements_all.txt b/requirements_all.txt index b1863df1e4658e..1491e1dbff4122 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -730,7 +730,7 @@ pyairvisual==2.0.1 pyalarmdotcom==0.3.2 # homeassistant.components.arlo -pyarlo==0.1.8 +pyarlo==0.1.9 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.5 diff --git a/tests/components/sensor/test_arlo.py b/tests/components/sensor/test_arlo.py new file mode 100644 index 00000000000000..d31490ab2afa10 --- /dev/null +++ b/tests/components/sensor/test_arlo.py @@ -0,0 +1,240 @@ +"""The tests for the Netgear Arlo sensors.""" +from collections import namedtuple +from unittest.mock import patch, MagicMock +import pytest +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, ATTR_ATTRIBUTION) +from homeassistant.components.sensor import arlo +from homeassistant.components.arlo import DATA_ARLO + + +def _get_named_tuple(input_dict): + return namedtuple('Struct', input_dict.keys())(*input_dict.values()) + + +def _get_sensor(name='Last', sensor_type='last_capture', data=None): + if data is None: + data = {} + return arlo.ArloSensor(name, data, sensor_type) + + +@pytest.fixture() +def default_sensor(): + """Create an ArloSensor with default values.""" + return _get_sensor() + + +@pytest.fixture() +def battery_sensor(): + """Create an ArloSensor with battery data.""" + data = _get_named_tuple({ + 'battery_level': 50 + }) + return _get_sensor('Battery Level', 'battery_level', data) + + +@pytest.fixture() +def temperature_sensor(): + """Create a temperature ArloSensor.""" + return _get_sensor('Temperature', 'temperature') + + +@pytest.fixture() +def humidity_sensor(): + """Create a humidity ArloSensor.""" + return _get_sensor('Humidity', 'humidity') + + +@pytest.fixture() +def cameras_sensor(): + """Create a total cameras ArloSensor.""" + data = _get_named_tuple({ + 'cameras': [0, 0] + }) + return _get_sensor('Arlo Cameras', 'total_cameras', data) + + +@pytest.fixture() +def captured_sensor(): + """Create a captured today ArloSensor.""" + data = _get_named_tuple({ + 'captured_today': [0, 0, 0, 0, 0] + }) + return _get_sensor('Captured Today', 'captured_today', data) + + +class PlatformSetupFixture(): + """Fixture for testing platform setup call to add_devices().""" + + def __init__(self): + """Instantiate the platform setup fixture.""" + self.sensors = None + self.update = False + + def add_devices(self, sensors, update): + """Mock method for adding devices.""" + self.sensors = sensors + self.update = update + + +@pytest.fixture() +def platform_setup(): + """Create an instance of the PlatformSetupFixture class.""" + return PlatformSetupFixture() + + +@pytest.fixture() +def sensor_with_hass_data(default_sensor, hass): + """Create a sensor with async_dispatcher_connected mocked.""" + hass.data = {} + default_sensor.hass = hass + return default_sensor + + +@pytest.fixture() +def mock_dispatch(): + """Mock the dispatcher connect method.""" + target = 'homeassistant.components.sensor.arlo.async_dispatcher_connect' + with patch(target, MagicMock()) as _mock: + yield _mock + + +def test_setup_with_no_data(platform_setup, hass): + """Test setup_platform with no data.""" + arlo.setup_platform(hass, None, platform_setup.add_devices) + assert platform_setup.sensors is None + assert not platform_setup.update + + +def test_setup_with_valid_data(platform_setup, hass): + """Test setup_platform with valid data.""" + config = { + 'monitored_conditions': [ + 'last_capture', + 'total_cameras', + 'captured_today', + 'battery_level', + 'signal_strength', + 'temperature', + 'humidity', + 'air_quality' + ] + } + + hass.data[DATA_ARLO] = _get_named_tuple({ + 'cameras': [_get_named_tuple({ + 'name': 'Camera', + 'model_id': 'ABC1000' + })], + 'base_stations': [_get_named_tuple({ + 'name': 'Base Station', + 'model_id': 'ABC1000' + })] + }) + + arlo.setup_platform(hass, config, platform_setup.add_devices) + assert len(platform_setup.sensors) == 8 + assert platform_setup.update + + +def test_sensor_name(default_sensor): + """Test the name property.""" + assert default_sensor.name == 'Last' + + +async def test_async_added_to_hass(sensor_with_hass_data, mock_dispatch): + """Test dispatcher called when added.""" + await sensor_with_hass_data.async_added_to_hass() + assert len(mock_dispatch.mock_calls) == 1 + kall = mock_dispatch.call_args + args, kwargs = kall + assert len(args) == 3 + assert args[0] == sensor_with_hass_data.hass + assert args[1] == 'arlo_update' + assert not kwargs + + +def test_sensor_state_default(default_sensor): + """Test the state property.""" + assert default_sensor.state is None + + +def test_sensor_icon_battery(battery_sensor): + """Test the battery icon.""" + assert battery_sensor.icon == 'mdi:battery-50' + + +def test_sensor_icon(temperature_sensor): + """Test the icon property.""" + assert temperature_sensor.icon == 'mdi:thermometer' + + +def test_unit_of_measure(default_sensor, battery_sensor): + """Test the unit_of_measurement property.""" + assert default_sensor.unit_of_measurement is None + assert battery_sensor.unit_of_measurement == '%' + + +def test_device_class(default_sensor, temperature_sensor, humidity_sensor): + """Test the device_class property.""" + assert default_sensor.device_class is None + assert temperature_sensor.device_class == DEVICE_CLASS_TEMPERATURE + assert humidity_sensor.device_class == DEVICE_CLASS_HUMIDITY + + +def test_update_total_cameras(cameras_sensor): + """Test update method for total_cameras sensor type.""" + cameras_sensor.update() + assert cameras_sensor.state == 2 + + +def test_update_captured_today(captured_sensor): + """Test update method for captured_today sensor type.""" + captured_sensor.update() + assert captured_sensor.state == 5 + + +def _test_attributes(sensor_type): + data = _get_named_tuple({ + 'model_id': 'TEST123' + }) + sensor = _get_sensor('test', sensor_type, data) + attrs = sensor.device_state_attributes + assert attrs.get(ATTR_ATTRIBUTION) == 'Data provided by arlo.netgear.com' + assert attrs.get('brand') == 'Netgear Arlo' + assert attrs.get('model') == 'TEST123' + + +def test_state_attributes(): + """Test attributes for camera sensor types.""" + _test_attributes('battery_level') + _test_attributes('signal_strength') + _test_attributes('temperature') + _test_attributes('humidity') + _test_attributes('air_quality') + + +def test_attributes_total_cameras(cameras_sensor): + """Test attributes for total cameras sensor type.""" + attrs = cameras_sensor.device_state_attributes + assert attrs.get(ATTR_ATTRIBUTION) == 'Data provided by arlo.netgear.com' + assert attrs.get('brand') == 'Netgear Arlo' + assert attrs.get('model') is None + + +def _test_update(sensor_type, key, value): + data = _get_named_tuple({ + key: value + }) + sensor = _get_sensor('test', sensor_type, data) + sensor.update() + assert sensor.state == value + + +def test_update(): + """Test update method for direct transcription sensor types.""" + _test_update('battery_level', 'battery_level', 100) + _test_update('signal_strength', 'signal_strength', 100) + _test_update('temperature', 'ambient_temperature', 21.4) + _test_update('humidity', 'ambient_humidity', 45.1) + _test_update('air_quality', 'ambient_air_quality', 14.2) From 99709657187b4638c736b478598fff22eb3d6ffa Mon Sep 17 00:00:00 2001 From: Mattias Welponer Date: Fri, 6 Jul 2018 23:05:34 +0200 Subject: [PATCH 064/147] Add HomematicIP Cloud Config Flow and Entries loading (#14861) * Add HomematicIP Cloud to config flow * Inititial trial for config_flow * Integrations text files * Load and write config_flow and init homematicip_cloud * Split into dedicated files * Ceanup of text messages * Working config_flow * Move imports inside a function * Enable laoding even no accesspoints are defined * Revert unnecassary changes in CONFIG_SCHEMA * Better error handling * fix flask8 * Migration to async for token generation * A few fixes * Simplify config_flow * Bump version to 9.6 with renamed package * Requirements file * First fixes after review * Implement async_step_import * Cleanup for Config Flow * First tests for homematicip_cloud setup * Remove config_flow tests * Really remove all things * Fix comment * Update picture * Add support for async_setup_entry to switch and climate platform * Update path of the config_flow picture * Refactoring for better tesability * Further tests implemented * Move 3th party lib inside function * Fix lint * Update requirments_test_all.txt file * UPdate of requirments_test_all.txt did not work * Furder cleanup in websocket connection * Remove a test for the hap * Revert "Remove a test for the hap" This reverts commit 968d58cba108e0f371022c7ab540374aa2ab13f4. * First tests implemented for config_flow * Fix lint * Rework of client registration process * Implemented tests for config_flow 100% coverage * Cleanup * Cleanup comments and code * Try to fix import problem * Add homematicip to the test env requirements --- .../binary_sensor/homematicip_cloud.py | 15 +- .../components/climate/homematicip_cloud.py | 14 +- homeassistant/components/homematicip_cloud.py | 262 ------------------ .../homematicip_cloud/.translations/en.json | 30 ++ .../components/homematicip_cloud/__init__.py | 65 +++++ .../homematicip_cloud/config_flow.py | 97 +++++++ .../components/homematicip_cloud/const.py | 23 ++ .../components/homematicip_cloud/device.py | 71 +++++ .../components/homematicip_cloud/errors.py | 22 ++ .../components/homematicip_cloud/hap.py | 256 +++++++++++++++++ .../components/homematicip_cloud/strings.json | 30 ++ .../components/light/homematicip_cloud.py | 15 +- .../components/sensor/homematicip_cloud.py | 14 +- homeassistant/components/switch/__init__.py | 12 +- .../components/switch/homematicip_cloud.py | 13 +- homeassistant/config_entries.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + .../components/homematicip_cloud/__init__.py | 1 + .../homematicip_cloud/test_config_flow.py | 150 ++++++++++ .../components/homematicip_cloud/test_hap.py | 113 ++++++++ .../components/homematicip_cloud/test_init.py | 103 +++++++ 23 files changed, 1020 insertions(+), 293 deletions(-) delete mode 100644 homeassistant/components/homematicip_cloud.py create mode 100644 homeassistant/components/homematicip_cloud/.translations/en.json create mode 100644 homeassistant/components/homematicip_cloud/__init__.py create mode 100644 homeassistant/components/homematicip_cloud/config_flow.py create mode 100644 homeassistant/components/homematicip_cloud/const.py create mode 100644 homeassistant/components/homematicip_cloud/device.py create mode 100644 homeassistant/components/homematicip_cloud/errors.py create mode 100644 homeassistant/components/homematicip_cloud/hap.py create mode 100644 homeassistant/components/homematicip_cloud/strings.json create mode 100644 tests/components/homematicip_cloud/__init__.py create mode 100644 tests/components/homematicip_cloud/test_config_flow.py create mode 100644 tests/components/homematicip_cloud/test_hap.py create mode 100644 tests/components/homematicip_cloud/test_init.py diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py index 40ffe4984020c9..72a7db1ac7a8ec 100644 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -9,8 +9,8 @@ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) DEPENDENCIES = ['homematicip_cloud'] @@ -26,12 +26,15 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the HomematicIP binary sensor devices.""" + """Set up the binary sensor devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP binary sensor from a config entry.""" from homematicip.device import (ShutterContact, MotionDetectorIndoor) - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: if isinstance(device, ShutterContact): diff --git a/homeassistant/components/climate/homematicip_cloud.py b/homeassistant/components/climate/homematicip_cloud.py index bf96f1f746d8c2..8cf47159c103fd 100644 --- a/homeassistant/components/climate/homematicip_cloud.py +++ b/homeassistant/components/climate/homematicip_cloud.py @@ -12,8 +12,8 @@ STATE_AUTO, STATE_MANUAL) from homeassistant.const import TEMP_CELSIUS from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) _LOGGER = logging.getLogger(__name__) @@ -30,12 +30,14 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HomematicIP climate devices.""" - from homematicip.group import HeatingGroup + pass + - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP climate from a config entry.""" + from homematicip.group import HeatingGroup + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.groups: if isinstance(device, HeatingGroup): diff --git a/homeassistant/components/homematicip_cloud.py b/homeassistant/components/homematicip_cloud.py deleted file mode 100644 index 859841dfca64bc..00000000000000 --- a/homeassistant/components/homematicip_cloud.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -Support for HomematicIP components. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/homematicip_cloud/ -""" - -import asyncio -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.entity import Entity -from homeassistant.core import callback - -REQUIREMENTS = ['homematicip==0.9.4'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'homematicip_cloud' - -COMPONENTS = [ - 'sensor', - 'binary_sensor', - 'switch', - 'light', - 'climate', -] - -CONF_NAME = 'name' -CONF_ACCESSPOINT = 'accesspoint' -CONF_AUTHTOKEN = 'authtoken' - -CONFIG_SCHEMA = vol.Schema({ - vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({ - vol.Optional(CONF_NAME): vol.Any(cv.string), - vol.Required(CONF_ACCESSPOINT): cv.string, - vol.Required(CONF_AUTHTOKEN): cv.string, - })]), -}, extra=vol.ALLOW_EXTRA) - -HMIP_ACCESS_POINT = 'Access Point' -HMIP_HUB = 'HmIP-HUB' - -ATTR_HOME_ID = 'home_id' -ATTR_HOME_NAME = 'home_name' -ATTR_DEVICE_ID = 'device_id' -ATTR_DEVICE_LABEL = 'device_label' -ATTR_STATUS_UPDATE = 'status_update' -ATTR_FIRMWARE_STATE = 'firmware_state' -ATTR_UNREACHABLE = 'unreachable' -ATTR_LOW_BATTERY = 'low_battery' -ATTR_MODEL_TYPE = 'model_type' -ATTR_GROUP_TYPE = 'group_type' -ATTR_DEVICE_RSSI = 'device_rssi' -ATTR_DUTY_CYCLE = 'duty_cycle' -ATTR_CONNECTED = 'connected' -ATTR_SABOTAGE = 'sabotage' -ATTR_OPERATION_LOCK = 'operation_lock' - - -async def async_setup(hass, config): - """Set up the HomematicIP component.""" - from homematicip.base.base_connection import HmipConnectionError - - hass.data.setdefault(DOMAIN, {}) - accesspoints = config.get(DOMAIN, []) - for conf in accesspoints: - _websession = async_get_clientsession(hass) - _hmip = HomematicipConnector(hass, conf, _websession) - try: - await _hmip.init() - except HmipConnectionError: - _LOGGER.error('Failed to connect to the HomematicIP server, %s.', - conf.get(CONF_ACCESSPOINT)) - return False - - home = _hmip.home - home.name = conf.get(CONF_NAME) - home.label = HMIP_ACCESS_POINT - home.modelType = HMIP_HUB - - hass.data[DOMAIN][home.id] = home - _LOGGER.info('Connected to the HomematicIP server, %s.', - conf.get(CONF_ACCESSPOINT)) - homeid = {ATTR_HOME_ID: home.id} - for component in COMPONENTS: - hass.async_add_job(async_load_platform(hass, component, DOMAIN, - homeid, config)) - - hass.loop.create_task(_hmip.connect()) - return True - - -class HomematicipConnector: - """Manages HomematicIP http and websocket connection.""" - - def __init__(self, hass, config, websession): - """Initialize HomematicIP cloud connection.""" - from homematicip.async.home import AsyncHome - - self._hass = hass - self._ws_close_requested = False - self._retry_task = None - self._tries = 0 - self._accesspoint = config.get(CONF_ACCESSPOINT) - _authtoken = config.get(CONF_AUTHTOKEN) - - self.home = AsyncHome(hass.loop, websession) - self.home.set_auth_token(_authtoken) - - self.home.on_update(self.async_update) - self._accesspoint_connected = True - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.close()) - - async def init(self): - """Initialize connection.""" - await self.home.init(self._accesspoint) - await self.home.get_current_state() - - @callback - def async_update(self, *args, **kwargs): - """Async update the home device. - - Triggered when the hmip HOME_CHANGED event has fired. - There are several occasions for this event to happen. - We are only interested to check whether the access point - is still connected. If not, device state changes cannot - be forwarded to hass. So if access point is disconnected all devices - are set to unavailable. - """ - if not self.home.connected: - _LOGGER.error( - "HMIP access point has lost connection with the cloud") - self._accesspoint_connected = False - self.set_all_to_unavailable() - elif not self._accesspoint_connected: - # Explicitly getting an update as device states might have - # changed during access point disconnect.""" - - job = self._hass.async_add_job(self.get_state()) - job.add_done_callback(self.get_state_finished) - - async def get_state(self): - """Update hmip state and tell hass.""" - await self.home.get_current_state() - self.update_all() - - def get_state_finished(self, future): - """Execute when get_state coroutine has finished.""" - from homematicip.base.base_connection import HmipConnectionError - - try: - future.result() - except HmipConnectionError: - # Somehow connection could not recover. Will disconnect and - # so reconnect loop is taking over. - _LOGGER.error( - "updating state after himp access point reconnect failed.") - self._hass.async_add_job(self.home.disable_events()) - - def set_all_to_unavailable(self): - """Set all devices to unavailable and tell Hass.""" - for device in self.home.devices: - device.unreach = True - self.update_all() - - def update_all(self): - """Signal all devices to update their state.""" - for device in self.home.devices: - device.fire_update_event() - - async def _handle_connection(self): - """Handle websocket connection.""" - from homematicip.base.base_connection import HmipConnectionError - - await self.home.get_current_state() - hmip_events = await self.home.enable_events() - try: - await hmip_events - except HmipConnectionError: - return - - async def connect(self): - """Start websocket connection.""" - self._tries = 0 - while True: - await self._handle_connection() - if self._ws_close_requested: - break - self._ws_close_requested = False - self._tries += 1 - try: - self._retry_task = self._hass.async_add_job(asyncio.sleep( - 2 ** min(9, self._tries), loop=self._hass.loop)) - await self._retry_task - except asyncio.CancelledError: - break - _LOGGER.info('Reconnect (%s) to the HomematicIP cloud server.', - self._tries) - - async def close(self): - """Close the websocket connection.""" - self._ws_close_requested = True - if self._retry_task is not None: - self._retry_task.cancel() - await self.home.disable_events() - _LOGGER.info("Closed connection to HomematicIP cloud server.") - - -class HomematicipGenericDevice(Entity): - """Representation of an HomematicIP generic device.""" - - def __init__(self, home, device, post=None): - """Initialize the generic device.""" - self._home = home - self._device = device - self.post = post - _LOGGER.info('Setting up %s (%s)', self.name, - self._device.modelType) - - async def async_added_to_hass(self): - """Register callbacks.""" - self._device.on_update(self._device_changed) - - def _device_changed(self, json, **kwargs): - """Handle device state changes.""" - _LOGGER.debug('Event %s (%s)', self.name, self._device.modelType) - self.async_schedule_update_ha_state() - - @property - def name(self): - """Return the name of the generic device.""" - name = self._device.label - if self._home.name is not None: - name = "{} {}".format(self._home.name, name) - if self.post is not None: - name = "{} {}".format(name, self.post) - return name - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def available(self): - """Device available.""" - return not self._device.unreach - - @property - def device_state_attributes(self): - """Return the state attributes of the generic device.""" - return { - ATTR_LOW_BATTERY: self._device.lowBat, - ATTR_MODEL_TYPE: self._device.modelType - } diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json new file mode 100644 index 00000000000000..887a3a5780b0eb --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "HomematicIP Cloud", + "step": { + "init": { + "title": "Pick HomematicIP Accesspoint", + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "pin": "Pin Code (optional)", + "name": "Name (optional, used as name prefix for all devices)" + } + }, + "link": { + "title": "Link Accesspoint", + "description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + } + }, + "error": { + "register_failed": "Failed to register, please try again.", + "invalid_pin": "Invalid PIN, please try again.", + "press_the_button": "Please press the blue button.", + "timeout_button": "Blue button press timeout, please try again." + }, + "abort": { + "unknown": "Unknown error occurred.", + "conection_aborted": "Could not connect to HMIP server", + "already_configured": "Accesspoint is already configured" + } + } +} diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py new file mode 100644 index 00000000000000..3ff4e438f53c7f --- /dev/null +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -0,0 +1,65 @@ +""" +Support for HomematicIP components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/homematicip_cloud/ +""" + +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +from .const import ( + DOMAIN, HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_NAME, + CONF_ACCESSPOINT, CONF_AUTHTOKEN, CONF_NAME) +# Loading the config flow file will register the flow +from .config_flow import configured_haps +from .hap import HomematicipHAP, HomematicipAuth # noqa: F401 +from .device import HomematicipGenericDevice # noqa: F401 + +REQUIREMENTS = ['homematicip==0.9.6'] + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({ + vol.Optional(CONF_NAME, default=''): vol.Any(cv.string), + vol.Required(CONF_ACCESSPOINT): cv.string, + vol.Required(CONF_AUTHTOKEN): cv.string, + })]), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the HomematicIP component.""" + hass.data[DOMAIN] = {} + + accesspoints = config.get(DOMAIN, []) + + for conf in accesspoints: + if conf[CONF_ACCESSPOINT] not in configured_haps(hass): + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, source='import', data={ + HMIPC_HAPID: conf[CONF_ACCESSPOINT], + HMIPC_AUTHTOKEN: conf[CONF_AUTHTOKEN], + HMIPC_NAME: conf[CONF_NAME], + } + )) + + return True + + +async def async_setup_entry(hass, entry): + """Set up an accsspoint from a config entry.""" + hap = HomematicipHAP(hass, entry) + hapid = entry.data[HMIPC_HAPID].replace('-', '').upper() + hass.data[DOMAIN][hapid] = hap + return await hap.async_setup() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hap = hass.data[DOMAIN].pop(entry.data[HMIPC_HAPID]) + return await hap.async_reset() diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py new file mode 100644 index 00000000000000..9e5356d914a5a6 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow to configure HomematicIP Cloud.""" +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import callback + +from .const import ( + DOMAIN as HMIPC_DOMAIN, _LOGGER, + HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME) +from .hap import HomematicipAuth + + +@callback +def configured_haps(hass): + """Return a set of the configured accesspoints.""" + return set(entry.data[HMIPC_HAPID] for entry + in hass.config_entries.async_entries(HMIPC_DOMAIN)) + + +@config_entries.HANDLERS.register(HMIPC_DOMAIN) +class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler): + """Config flow HomematicIP Cloud.""" + + VERSION = 1 + + def __init__(self): + """Initialize HomematicIP Cloud config flow.""" + self.auth = None + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + errors = {} + + if user_input is not None: + user_input[HMIPC_HAPID] = \ + user_input[HMIPC_HAPID].replace('-', '').upper() + if user_input[HMIPC_HAPID] in configured_haps(self.hass): + return self.async_abort(reason='already_configured') + + self.auth = HomematicipAuth(self.hass, user_input) + connected = await self.auth.async_setup() + if connected: + _LOGGER.info("Connection established") + return await self.async_step_link() + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required(HMIPC_HAPID): str, + vol.Optional(HMIPC_PIN): str, + vol.Optional(HMIPC_NAME): str, + }), + errors=errors + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the HomematicIP Cloud accesspoint.""" + errors = {} + + pressed = await self.auth.async_checkbutton() + if pressed: + authtoken = await self.auth.async_register() + if authtoken: + _LOGGER.info("Write config entry") + return self.async_create_entry( + title=self.auth.config.get(HMIPC_HAPID), + data={ + HMIPC_HAPID: self.auth.config.get(HMIPC_HAPID), + HMIPC_AUTHTOKEN: authtoken, + HMIPC_NAME: self.auth.config.get(HMIPC_NAME) + }) + return self.async_abort(reason='conection_aborted') + else: + errors['base'] = 'press_the_button' + + return self.async_show_form(step_id='link', errors=errors) + + async def async_step_import(self, import_info): + """Import a new bridge as a config entry.""" + hapid = import_info[HMIPC_HAPID] + authtoken = import_info[HMIPC_AUTHTOKEN] + name = import_info[HMIPC_NAME] + + hapid = hapid.replace('-', '').upper() + if hapid in configured_haps(self.hass): + return self.async_abort(reason='already_configured') + + _LOGGER.info('Imported authentication for %s', hapid) + + return self.async_create_entry( + title=hapid, + data={ + HMIPC_HAPID: hapid, + HMIPC_AUTHTOKEN: authtoken, + HMIPC_NAME: name + } + ) diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py new file mode 100644 index 00000000000000..c40e577ae4a570 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/const.py @@ -0,0 +1,23 @@ +"""Constants for the HomematicIP Cloud component.""" +import logging + +_LOGGER = logging.getLogger('homeassistant.components.homematicip_cloud') + +DOMAIN = 'homematicip_cloud' + +COMPONENTS = [ + 'binary_sensor', + 'climate', + 'light', + 'sensor', + 'switch', +] + +CONF_NAME = 'name' +CONF_ACCESSPOINT = 'accesspoint' +CONF_AUTHTOKEN = 'authtoken' + +HMIPC_NAME = 'name' +HMIPC_HAPID = 'hapid' +HMIPC_AUTHTOKEN = 'authtoken' +HMIPC_PIN = 'pin' diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py new file mode 100644 index 00000000000000..94fe5f40be8db5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/device.py @@ -0,0 +1,71 @@ +"""GenericDevice for the HomematicIP Cloud component.""" +import logging + +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_HOME_ID = 'home_id' +ATTR_HOME_NAME = 'home_name' +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_LABEL = 'device_label' +ATTR_STATUS_UPDATE = 'status_update' +ATTR_FIRMWARE_STATE = 'firmware_state' +ATTR_UNREACHABLE = 'unreachable' +ATTR_LOW_BATTERY = 'low_battery' +ATTR_MODEL_TYPE = 'model_type' +ATTR_GROUP_TYPE = 'group_type' +ATTR_DEVICE_RSSI = 'device_rssi' +ATTR_DUTY_CYCLE = 'duty_cycle' +ATTR_CONNECTED = 'connected' +ATTR_SABOTAGE = 'sabotage' +ATTR_OPERATION_LOCK = 'operation_lock' + + +class HomematicipGenericDevice(Entity): + """Representation of an HomematicIP generic device.""" + + def __init__(self, home, device, post=None): + """Initialize the generic device.""" + self._home = home + self._device = device + self.post = post + _LOGGER.info('Setting up %s (%s)', self.name, + self._device.modelType) + + async def async_added_to_hass(self): + """Register callbacks.""" + self._device.on_update(self._device_changed) + + def _device_changed(self, json, **kwargs): + """Handle device state changes.""" + _LOGGER.debug('Event %s (%s)', self.name, self._device.modelType) + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the generic device.""" + name = self._device.label + if (self._home.name is not None and self._home.name != ''): + name = "{} {}".format(self._home.name, name) + if (self.post is not None and self.post != ''): + name = "{} {}".format(name, self.post) + return name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def available(self): + """Device available.""" + return not self._device.unreach + + @property + def device_state_attributes(self): + """Return the state attributes of the generic device.""" + return { + ATTR_LOW_BATTERY: self._device.lowBat, + ATTR_MODEL_TYPE: self._device.modelType + } diff --git a/homeassistant/components/homematicip_cloud/errors.py b/homeassistant/components/homematicip_cloud/errors.py new file mode 100644 index 00000000000000..cb2925d1a70450 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/errors.py @@ -0,0 +1,22 @@ +"""Errors for the HomematicIP component.""" +from homeassistant.exceptions import HomeAssistantError + + +class HmipcException(HomeAssistantError): + """Base class for HomematicIP exceptions.""" + + +class HmipcConnectionError(HmipcException): + """Unable to connect to the HomematicIP cloud server.""" + + +class HmipcConnectionWait(HmipcException): + """Wait for registration to the HomematicIP cloud server.""" + + +class HmipcRegistrationFailed(HmipcException): + """Registration on HomematicIP cloud failed.""" + + +class HmipcPressButton(HmipcException): + """User needs to press the blue button.""" diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py new file mode 100644 index 00000000000000..a4e3e78e860805 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -0,0 +1,256 @@ +"""Accesspoint for the HomematicIP Cloud component.""" +import asyncio +import logging + +from homeassistant import config_entries +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import callback + +from .const import ( + HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME, + COMPONENTS) +from .errors import HmipcConnectionError + +_LOGGER = logging.getLogger(__name__) + + +class HomematicipAuth(object): + """Manages HomematicIP client registration.""" + + def __init__(self, hass, config): + """Initialize HomematicIP Cloud client registration.""" + self.hass = hass + self.config = config + self.auth = None + + async def async_setup(self): + """Connect to HomematicIP for registration.""" + try: + self.auth = await self.get_auth( + self.hass, + self.config.get(HMIPC_HAPID), + self.config.get(HMIPC_PIN) + ) + return True + except HmipcConnectionError: + return False + + async def async_checkbutton(self): + """Check blue butten has been pressed.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + await self.auth.isRequestAcknowledged() + return True + except HmipConnectionError: + return False + + async def async_register(self): + """Register client at HomematicIP.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + authtoken = await self.auth.requestAuthToken() + await self.auth.confirmAuthToken(authtoken) + return authtoken + except HmipConnectionError: + return False + + async def get_auth(self, hass, hapid, pin): + """Create a HomematicIP access point object.""" + from homematicip.aio.auth import AsyncAuth + from homematicip.base.base_connection import HmipConnectionError + + auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) + print(auth) + try: + await auth.init(hapid) + if pin: + auth.pin = pin + await auth.connectionRequest('HomeAssistant') + except HmipConnectionError: + return False + return auth + + +class HomematicipHAP(object): + """Manages HomematicIP http and websocket connection.""" + + def __init__(self, hass, config_entry): + """Initialize HomematicIP cloud connection.""" + self.hass = hass + self.config_entry = config_entry + self.home = None + + self._ws_close_requested = False + self._retry_task = None + self._tries = 0 + self._accesspoint_connected = True + self._retry_setup = None + + async def async_setup(self, tries=0): + """Initialize connection.""" + try: + self.home = await self.get_hap( + self.hass, + self.config_entry.data.get(HMIPC_HAPID), + self.config_entry.data.get(HMIPC_AUTHTOKEN), + self.config_entry.data.get(HMIPC_NAME) + ) + except HmipcConnectionError: + retry_delay = 2 ** min(tries + 1, 6) + _LOGGER.error("Error connecting to HomematicIP with HAP %s. " + "Retrying in %d seconds.", + self.config_entry.data.get(HMIPC_HAPID), retry_delay) + + async def retry_setup(_now): + """Retry setup.""" + if await self.async_setup(tries + 1): + self.config_entry.state = config_entries.ENTRY_STATE_LOADED + + self._retry_setup = self.hass.helpers.event.async_call_later( + retry_delay, retry_setup) + + return False + + _LOGGER.info('Connected to HomematicIP with HAP %s.', + self.config_entry.data.get(HMIPC_HAPID)) + + for component in COMPONENTS: + self.hass.async_add_job( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, component) + ) + return True + + @callback + def async_update(self, *args, **kwargs): + """Async update the home device. + + Triggered when the hmip HOME_CHANGED event has fired. + There are several occasions for this event to happen. + We are only interested to check whether the access point + is still connected. If not, device state changes cannot + be forwarded to hass. So if access point is disconnected all devices + are set to unavailable. + """ + if not self.home.connected: + _LOGGER.error( + "HMIP access point has lost connection with the cloud") + self._accesspoint_connected = False + self.set_all_to_unavailable() + elif not self._accesspoint_connected: + # Explicitly getting an update as device states might have + # changed during access point disconnect.""" + + job = self.hass.async_add_job(self.get_state()) + job.add_done_callback(self.get_state_finished) + + async def get_state(self): + """Update hmip state and tell hass.""" + await self.home.get_current_state() + self.update_all() + + def get_state_finished(self, future): + """Execute when get_state coroutine has finished.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + future.result() + except HmipConnectionError: + # Somehow connection could not recover. Will disconnect and + # so reconnect loop is taking over. + _LOGGER.error( + "updating state after himp access point reconnect failed.") + self.hass.async_add_job(self.home.disable_events()) + + def set_all_to_unavailable(self): + """Set all devices to unavailable and tell Hass.""" + for device in self.home.devices: + device.unreach = True + self.update_all() + + def update_all(self): + """Signal all devices to update their state.""" + for device in self.home.devices: + device.fire_update_event() + + async def _handle_connection(self): + """Handle websocket connection.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + await self.home.get_current_state() + except HmipConnectionError: + return + hmip_events = await self.home.enable_events() + try: + await hmip_events + except HmipConnectionError: + return + + async def async_connect(self): + """Start websocket connection.""" + from homematicip.base.base_connection import HmipConnectionError + + tries = 0 + while True: + try: + await self.home.get_current_state() + hmip_events = await self.home.enable_events() + tries = 0 + await hmip_events + except HmipConnectionError: + pass + + if self._ws_close_requested: + break + self._ws_close_requested = False + + tries += 1 + retry_delay = 2 ** min(tries + 1, 6) + _LOGGER.error("Error connecting to HomematicIP with HAP %s. " + "Retrying in %d seconds.", + self.config_entry.data.get(HMIPC_HAPID), retry_delay) + try: + self._retry_task = self.hass.async_add_job(asyncio.sleep( + retry_delay, loop=self.hass.loop)) + await self._retry_task + except asyncio.CancelledError: + break + + async def async_reset(self): + """Close the websocket connection.""" + self._ws_close_requested = True + if self._retry_setup is not None: + self._retry_setup.cancel() + if self._retry_task is not None: + self._retry_task.cancel() + self.home.disable_events() + _LOGGER.info("Closed connection to HomematicIP cloud server.") + for component in COMPONENTS: + await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, component) + return True + + async def get_hap(self, hass, hapid, authtoken, name): + """Create a HomematicIP access point object.""" + from homematicip.aio.home import AsyncHome + from homematicip.base.base_connection import HmipConnectionError + + home = AsyncHome(hass.loop, async_get_clientsession(hass)) + + home.name = name + home.label = 'Access Point' + home.modelType = 'HmIP-HAP' + + home.set_auth_token(authtoken) + try: + await home.init(hapid) + await home.get_current_state() + except HmipConnectionError: + raise HmipcConnectionError + home.on_update(self.async_update) + hass.loop.create_task(self.async_connect()) + + return home diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json new file mode 100644 index 00000000000000..887a3a5780b0eb --- /dev/null +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "HomematicIP Cloud", + "step": { + "init": { + "title": "Pick HomematicIP Accesspoint", + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "pin": "Pin Code (optional)", + "name": "Name (optional, used as name prefix for all devices)" + } + }, + "link": { + "title": "Link Accesspoint", + "description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + } + }, + "error": { + "register_failed": "Failed to register, please try again.", + "invalid_pin": "Invalid PIN, please try again.", + "press_the_button": "Please press the blue button.", + "timeout_button": "Blue button press timeout, please try again." + }, + "abort": { + "unknown": "Unknown error occurred.", + "conection_aborted": "Could not connect to HMIP server", + "already_configured": "Accesspoint is already configured" + } + } +} diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py index e433da44ae768b..5984fb0365792e 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -9,8 +9,8 @@ from homeassistant.components.light import Light from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) DEPENDENCIES = ['homematicip_cloud'] @@ -23,13 +23,16 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the HomematicIP light devices.""" + """Old way of setting up HomematicIP lights.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP lights from a config entry.""" from homematicip.device import ( BrandSwitchMeasuring) - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: if isinstance(device, BrandSwitchMeasuring): diff --git a/homeassistant/components/sensor/homematicip_cloud.py b/homeassistant/components/sensor/homematicip_cloud.py index ccd1949cc3b2bd..0596bc0b6ccd3e 100644 --- a/homeassistant/components/sensor/homematicip_cloud.py +++ b/homeassistant/components/sensor/homematicip_cloud.py @@ -8,8 +8,8 @@ import logging from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) from homeassistant.const import ( TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE) @@ -36,15 +36,17 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HomematicIP sensors devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP sensors from a config entry.""" from homematicip.device import ( HeatingThermostat, TemperatureHumiditySensorWithoutDisplay, TemperatureHumiditySensorDisplay, MotionDetectorIndoor) - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [HomematicipAccesspointStatus(home)] - for device in home.devices: if isinstance(device, HeatingThermostat): devices.append(HomematicipHeatingThermostat(home, device)) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index bab2abbad0d83c..b9ee8126ed3c29 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -95,7 +95,7 @@ def toggle(hass, entity_id=None): async def async_setup(hass, config): """Track states and offer events for switches.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES) await component.async_setup(config) @@ -132,6 +132,16 @@ async def async_handle_switch_service(service): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class SwitchDevice(ToggleEntity): """Representation of a switch.""" diff --git a/homeassistant/components/switch/homematicip_cloud.py b/homeassistant/components/switch/homematicip_cloud.py index 9123d46c87b2ef..68884aaaa02963 100644 --- a/homeassistant/components/switch/homematicip_cloud.py +++ b/homeassistant/components/switch/homematicip_cloud.py @@ -9,8 +9,8 @@ from homeassistant.components.switch import SwitchDevice from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) DEPENDENCIES = ['homematicip_cloud'] @@ -24,13 +24,16 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HomematicIP switch devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP switch from a config entry.""" from homematicip.device import ( PlugableSwitch, PlugableSwitchMeasuring, BrandSwitchMeasuring) - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: if isinstance(device, BrandSwitchMeasuring): diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index be67ebd9cc3a2c..2e5613057f148f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -127,6 +127,7 @@ async def async_step_discovery(info): FLOWS = [ 'cast', 'deconz', + 'homematicip_cloud', 'hue', 'nest', 'sonos', diff --git a/requirements_all.txt b/requirements_all.txt index 1491e1dbff4122..c72e56821d6f11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -421,7 +421,7 @@ home-assistant-frontend==20180704.0 # homekit==0.6 # homeassistant.components.homematicip_cloud -homematicip==0.9.4 +homematicip==0.9.6 # homeassistant.components.remember_the_milk httplib2==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 476b3d96c3d2e1..aabbdc44bea154 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -83,6 +83,9 @@ holidays==0.9.5 # homeassistant.components.frontend home-assistant-frontend==20180704.0 +# homeassistant.components.homematicip_cloud +homematicip==0.9.6 + # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb influxdb==5.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7bf87c74de7266..9a5b4dd1a43e15 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -56,6 +56,7 @@ 'hbmqtt', 'holidays', 'home-assistant-frontend', + 'homematicip', 'influxdb', 'libpurecoollink', 'libsoundtouch', diff --git a/tests/components/homematicip_cloud/__init__.py b/tests/components/homematicip_cloud/__init__.py new file mode 100644 index 00000000000000..1d89bd73183c91 --- /dev/null +++ b/tests/components/homematicip_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the HomematicIP Cloud component.""" diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py new file mode 100644 index 00000000000000..1c2e54a1a5dfb8 --- /dev/null +++ b/tests/components/homematicip_cloud/test_config_flow.py @@ -0,0 +1,150 @@ +"""Tests for HomematicIP Cloud config flow.""" +from unittest.mock import patch + +from homeassistant.components.homematicip_cloud import hap as hmipc +from homeassistant.components.homematicip_cloud import config_flow, const + +from tests.common import MockConfigEntry, mock_coro + + +async def test_flow_works(hass): + """Test config flow works.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', return_value=mock_coro()), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_register', + return_value=mock_coro(True)): + hap.authtoken = 'ABC' + result = await flow.async_step_init(user_input=config) + + assert hap.authtoken == 'ABC' + assert result['type'] == 'create_entry' + + +async def test_flow_init_connection_error(hass): + """Test config flow with accesspoint connection error.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'form' + + +async def test_flow_link_connection_error(hass): + """Test config flow client registration connection error.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_register', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'abort' + + +async def test_flow_link_press_button(hass): + """Test config flow ask for pressing the blue button.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'form' + assert result['errors'] == {'base': 'press_the_button'} + + +async def test_init_flow_show_form(hass): + """Test config flow shows up with a form.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input=None) + assert result['type'] == 'form' + + +async def test_init_already_configured(hass): + """Test accesspoint is already configured.""" + MockConfigEntry(domain=const.DOMAIN, data={ + const.HMIPC_HAPID: 'ABC123', + }).add_to_hass(hass) + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'abort' + + +async def test_import_config(hass): + """Test importing a host with an existing config file.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'ABC123' + assert result['data'] == { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + } + + +async def test_import_existing_config(hass): + """Test abort of an existing accesspoint from config.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + MockConfigEntry(domain=const.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + }).add_to_hass(hass) + + result = await flow.async_step_import({ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + }) + + assert result['type'] == 'abort' diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py new file mode 100644 index 00000000000000..5344773fde6595 --- /dev/null +++ b/tests/components/homematicip_cloud/test_hap.py @@ -0,0 +1,113 @@ +"""Test HomematicIP Cloud accesspoint.""" +from unittest.mock import Mock, patch + +from homeassistant.components.homematicip_cloud import hap as hmipc +from homeassistant.components.homematicip_cloud import const, errors +from tests.common import mock_coro + + +async def test_auth_setup(hass): + """Test auth setup for client registration.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', return_value=mock_coro()): + assert await hap.async_setup() is True + + +async def test_auth_setup_connection_error(hass): + """Test auth setup connection error behaviour.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', + side_effect=errors.HmipcConnectionError): + assert await hap.async_setup() is False + + +async def test_auth_auth_check_and_register(hass): + """Test auth client registration.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + hap.auth = Mock() + with patch.object(hap.auth, 'isRequestAcknowledged', + return_value=mock_coro()), \ + patch.object(hap.auth, 'requestAuthToken', + return_value=mock_coro('ABC')), \ + patch.object(hap.auth, 'confirmAuthToken', + return_value=mock_coro()): + assert await hap.async_checkbutton() is True + assert await hap.async_register() == 'ABC' + + +async def test_hap_setup_works(aioclient_mock): + """Test a successful setup of a accesspoint.""" + hass = Mock() + entry = Mock() + home = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', return_value=mock_coro(home)): + assert await hap.async_setup() is True + + assert hap.home is home + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 5 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'binary_sensor') + + +async def test_hap_setup_connection_error(): + """Test a failed accesspoint setup.""" + hass = Mock() + entry = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', + side_effect=errors.HmipcConnectionError): + assert await hap.async_setup() is False + + assert len(hass.async_add_job.mock_calls) == 0 + assert len(hass.config_entries.flow.async_init.mock_calls) == 0 + + +async def test_hap_reset_unloads_entry_if_setup(): + """Test calling reset while the entry has been setup.""" + hass = Mock() + entry = Mock() + home = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', return_value=mock_coro(home)): + assert await hap.async_setup() is True + + assert hap.home is home + assert len(hass.services.async_register.mock_calls) == 0 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 5 + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + await hap.async_reset() + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 5 diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py new file mode 100644 index 00000000000000..185372272471bd --- /dev/null +++ b/tests/components/homematicip_cloud/test_init.py @@ -0,0 +1,103 @@ +"""Test HomematicIP Cloud setup process.""" + +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import homematicip_cloud as hmipc + +from tests.common import mock_coro, MockConfigEntry + + +async def test_config_with_accesspoint_passed_to_config_entry(hass): + """Test that config for a accesspoint are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=[]): + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'name', + } + }) is True + + # Flow started for the access point + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_already_registered_not_passed_to_config_entry(hass): + """Test that an already registered accesspoint does not get imported.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=['ABC123']): + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'name', + } + }) is True + + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_setup_entry_successful(hass): + """Test setup entry is successful.""" + entry = MockConfigEntry(domain=hmipc.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + }) + entry.add_to_hass(hass) + with patch.object(hmipc, 'HomematicipHAP') as mock_hap: + mock_hap.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'hmip', + } + }) is True + + assert len(mock_hap.mock_calls) == 2 + + +async def test_setup_defined_accesspoint(hass): + """Test we initiate config entry for the accesspoint.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=[]): + mock_config_entries.flow.async_init.return_value = mock_coro() + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'hmip', + } + }) is True + + assert len(mock_config_entries.flow.mock_calls) == 1 + assert mock_config_entries.flow.mock_calls[0][2]['data'] == { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry(domain=hmipc.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + }) + entry.add_to_hass(hass) + + with patch.object(hmipc, 'HomematicipHAP') as mock_hap: + mock_hap.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hmipc.DOMAIN, {}) is True + + assert len(mock_hap.return_value.mock_calls) == 1 + + mock_hap.return_value.async_reset.return_value = mock_coro(True) + assert await hmipc.async_unload_entry(hass, entry) + assert len(mock_hap.return_value.async_reset.mock_calls) == 1 + assert hass.data[hmipc.DOMAIN] == {} From f8f8da959af2122b23e4bec04cedd1a6a29f23a7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 7 Jul 2018 11:05:44 +0200 Subject: [PATCH 065/147] Upgrade youtube_dl to 2018.07.04 (#15323) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 85895fdd7516e9..21accdf84b3a2d 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.06.25'] +REQUIREMENTS = ['youtube_dl==2018.07.04'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c72e56821d6f11..4ac29b5775f14e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1435,7 +1435,7 @@ yeelight==0.4.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.06.25 +youtube_dl==2018.07.04 # homeassistant.components.light.zengge zengge==0.2 From b5c7afcf75a25cfd7df439d7c35744b975d3fab7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 7 Jul 2018 11:06:00 +0200 Subject: [PATCH 066/147] Upgrade keyring to 13.2.0 (#15322) --- homeassistant/scripts/keyring.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index 0ca60894f9b983..ecb31bdef86163 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==13.1.0', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==13.2.0', 'keyrings.alt==3.1'] def run(args): diff --git a/requirements_all.txt b/requirements_all.txt index 4ac29b5775f14e..b6c1bb7dc41cb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -470,7 +470,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==13.1.0 +keyring==13.2.0 # homeassistant.scripts.keyring keyrings.alt==3.1 From dabbd7bd63f8208244a9a0f8817aca098d537822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 7 Jul 2018 12:06:49 +0300 Subject: [PATCH 067/147] Upgrade pytest to 3.6.3 (#15332) --- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index d6e92d5b8ffe30..c8d3be81468e0c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,5 +13,5 @@ pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.0 -pytest==3.6.2 +pytest==3.6.3 requests_mock==1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aabbdc44bea154..0f7e5a264eeb21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -14,7 +14,7 @@ pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.0 -pytest==3.6.2 +pytest==3.6.3 requests_mock==1.5 From bd62248841defdc8075deb1665630ac776cf4295 Mon Sep 17 00:00:00 2001 From: Tommy Jonsson Date: Sat, 7 Jul 2018 11:10:43 +0200 Subject: [PATCH 068/147] Add original message as dialogflow_query parameter (#15304) So you can access for example sessionId as {{ dialogflow_query.sessionId }} in intent templates. --- homeassistant/components/dialogflow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dialogflow.py b/homeassistant/components/dialogflow.py index 7a0918aab25e0d..28b3a05e403cb1 100644 --- a/homeassistant/components/dialogflow.py +++ b/homeassistant/components/dialogflow.py @@ -99,7 +99,8 @@ async def async_handle_message(hass, message): return None action = req.get('action', '') - parameters = req.get('parameters') + parameters = req.get('parameters').copy() + parameters["dialogflow_query"] = message dialogflow_response = DialogflowResponse(parameters) if action == "": From 02238b6412ce271010b4227f45344173b9f525ea Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 7 Jul 2018 17:48:02 +0300 Subject: [PATCH 069/147] Add python 3.7 to travis and tox (#14523) * Add python 3.7 to travis and tox * Use pyyaml from github * Don't version constraints * Fix version tag * Change to new pyyaml release * Python 3.7 requires xenial * Fix namespace detection * Use correct RegEx type * Update pexpect to 4.6 * Use correct validation for dictionaries * Disable Py37 incompatible packages * Upgrade all pexpect to 4.6 * Add explicit None as default param --- .travis.yml | 5 +++-- homeassistant/components/device_tracker/aruba.py | 2 +- homeassistant/components/device_tracker/asuswrt.py | 2 +- homeassistant/components/device_tracker/cisco_ios.py | 2 +- homeassistant/components/device_tracker/unifi_direct.py | 2 +- homeassistant/components/media_player/pandora.py | 2 +- homeassistant/components/vacuum/__init__.py | 2 +- homeassistant/loader.py | 3 ++- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 2 +- setup.py | 2 +- tests/components/mqtt/test_server.py | 6 ++++++ tests/components/sensor/test_geo_rss_events.py | 6 ++++++ tests/test_util/aiohttp.py | 6 ++++-- tox.ini | 2 +- 16 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index b089d3f89be327..5b3c43ec8c8bae 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,9 @@ matrix: env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 - # - python: "3.6-dev" - # env: TOXENV=py36 + - python: "3.7" + env: TOXENV=py37 + dist: xenial # allow_failures: # - python: "3.5" # env: TOXENV=typing diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index 92ef78f60f3b0b..61eee99e721607 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _DEVICES_REGEX = re.compile( r'(?P([^\s]+)?)\s+' + diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 5cb7e283c99721..bea02143d72a6b 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -19,7 +19,7 @@ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, CONF_PROTOCOL) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/cisco_ios.py b/homeassistant/components/device_tracker/cisco_ios.py index c13f622c5bf101..1afea2c1607f73 100644 --- a/homeassistant/components/device_tracker/cisco_ios.py +++ b/homeassistant/components/device_tracker/cisco_ios.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/device_tracker/unifi_direct.py b/homeassistant/components/device_tracker/unifi_direct.py index c3c4a48bb826ff..228443fe22ba66 100644 --- a/homeassistant/components/device_tracker/unifi_direct.py +++ b/homeassistant/components/device_tracker/unifi_direct.py @@ -16,7 +16,7 @@ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/pandora.py b/homeassistant/components/media_player/pandora.py index a47db7f633c4a6..90638cd9dfce36 100644 --- a/homeassistant/components/media_player/pandora.py +++ b/homeassistant/components/media_player/pandora.py @@ -22,7 +22,7 @@ STATE_IDLE) from homeassistant import util -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) # SUPPORT_VOLUME_SET is close to available but we need volume up/down diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 1b7d5685231602..880b3604a86a49 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -57,7 +57,7 @@ VACUUM_SEND_COMMAND_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ vol.Required(ATTR_COMMAND): cv.string, - vol.Optional(ATTR_PARAMS): vol.Any(cv.Dict, cv.ensure_list), + vol.Optional(ATTR_PARAMS): vol.Any(dict, cv.ensure_list), }) SERVICE_TO_METHOD = { diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 9e5efffdccbab1..153d00f92fce56 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -87,7 +87,8 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: # This prevents that when only # custom_components/switch/some_platform.py exists, # the import custom_components.switch would succeed. - if module.__spec__ and module.__spec__.origin == 'namespace': + # __file__ was unset for namespaces before Python 3.7 + if getattr(module, '__file__', None) is None: continue _LOGGER.info("Loaded %s from %s", comp_or_platform, path) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 32374b90135b89..66b17cf9bd99d5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ certifi>=2018.04.16 jinja2>=2.10 pip>=8.0.3 pytz>=2018.04 -pyyaml>=3.11,<4 +pyyaml>=3.13,<4 requests==2.19.1 voluptuous==0.11.1 diff --git a/requirements_all.txt b/requirements_all.txt index b6c1bb7dc41cb6..1949e52200b93b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ certifi>=2018.04.16 jinja2>=2.10 pip>=8.0.3 pytz>=2018.04 -pyyaml>=3.11,<4 +pyyaml>=3.13,<4 requests==2.19.1 voluptuous==0.11.1 @@ -630,7 +630,7 @@ pdunehd==1.3 # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora -pexpect==4.0.1 +pexpect==4.6.0 # homeassistant.components.rpi_pfio pifacecommon==4.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f7e5a264eeb21..2fb7153efb5255 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -113,7 +113,7 @@ paho-mqtt==1.3.1 # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora -pexpect==4.0.1 +pexpect==4.6.0 # homeassistant.components.pilight pilight==0.1.1 diff --git a/setup.py b/setup.py index 928d894c9d1a9f..bbf10dd309dc40 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ 'jinja2>=2.10', 'pip>=8.0.3', 'pytz>=2018.04', - 'pyyaml>=3.11,<4', + 'pyyaml>=3.13,<4', 'requests==2.19.1', 'voluptuous==0.11.1', ] diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 9b4c0c69ac63f2..1c37c9049f3188 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -1,5 +1,8 @@ """The tests for the MQTT component embedded server.""" from unittest.mock import Mock, MagicMock, patch +import sys + +import pytest from homeassistant.setup import setup_component import homeassistant.components.mqtt as mqtt @@ -7,6 +10,9 @@ from tests.common import get_test_home_assistant, mock_coro +# Until https://github.com/beerfactory/hbmqtt/pull/139 is released +@pytest.mark.skipif(sys.version_info[:2] >= (3, 7), + reason='Package incompatible with Python 3.7') class TestMQTT: """Test the MQTT component.""" diff --git a/tests/components/sensor/test_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py index f9ec83cc8be0e8..cc57c80143005a 100644 --- a/tests/components/sensor/test_geo_rss_events.py +++ b/tests/components/sensor/test_geo_rss_events.py @@ -1,7 +1,10 @@ """The test for the geo rss events sensor platform.""" import unittest from unittest import mock +import sys + import feedparser +import pytest from homeassistant.setup import setup_component from tests.common import load_fixture, get_test_home_assistant @@ -22,6 +25,9 @@ } +# Until https://github.com/kurtmckee/feedparser/pull/131 is released. +@pytest.mark.skipif(sys.version_info[:2] >= (3, 7), + reason='Package incompatible with Python 3.7') class TestGeoRssServiceUpdater(unittest.TestCase): """Test the GeoRss service updater.""" diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index e67d5de50d1bcc..0296b8c2fbab54 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -11,6 +11,8 @@ from aiohttp.client_exceptions import ClientResponseError +retype = type(re.compile('')) + class AiohttpClientMocker: """Mock Aiohttp client requests.""" @@ -40,7 +42,7 @@ def request(self, method, url, *, if content is None: content = b'' - if not isinstance(url, re._pattern_type): + if not isinstance(url, retype): url = URL(url) if params: url = url.with_query(params) @@ -146,7 +148,7 @@ def match_request(self, method, url, params=None): return False # regular expression matching - if isinstance(self._url, re._pattern_type): + if isinstance(self._url, retype): return self._url.search(str(url)) is not None if (self._url.scheme != url.scheme or self._url.host != url.host or diff --git a/tox.ini b/tox.ini index ca82c83d0fcb8e..578a431febf9cd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, lint, pylint, typing +envlist = py35, py36, py37, lint, pylint, typing skip_missing_interpreters = True [testenv] From b333dba8750994b2131732a04f6e6b9fd28f1e11 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 8 Jul 2018 17:25:15 +0200 Subject: [PATCH 070/147] Bump frontend to 20180708.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 0b9c8edd4117b5..ca886ec25f886d 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180704.0'] +REQUIREMENTS = ['home-assistant-frontend==20180708.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 1949e52200b93b..c84f484cabd8f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180704.0 +home-assistant-frontend==20180708.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2fb7153efb5255..88a54841b8ed5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180704.0 +home-assistant-frontend==20180708.0 # homeassistant.components.homematicip_cloud homematicip==0.9.6 From 703d71c06441a3e288087f4969c92874ae738616 Mon Sep 17 00:00:00 2001 From: sjabby Date: Sun, 8 Jul 2018 22:45:01 +0200 Subject: [PATCH 071/147] Frontend: Allow overriding default url when added to home screen (#15368) Frontend: Allow overriding default url when added to home screen --- homeassistant/components/frontend/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ca886ec25f886d..a6fb8735a668f9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -50,7 +50,7 @@ 'lang': 'en-US', 'name': 'Home Assistant', 'short_name': 'Assistant', - 'start_url': '/states', + 'start_url': '/?homescreen=1', 'theme_color': DEFAULT_THEME_COLOR } From 1ff329d9d6ec122460161a55ffed1cdbdd51bd75 Mon Sep 17 00:00:00 2001 From: Mattias Welponer Date: Mon, 9 Jul 2018 05:37:59 +0200 Subject: [PATCH 072/147] Add HomematicIP Cloud light power consumption and energie attributes (#15343) * Add power consumption and energie attributes * Fix lint * Change attribute name and include kwh --- .../components/light/homematicip_cloud.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py index 5984fb0365792e..5c513113f90fb4 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_POWER_CONSUMPTION = 'power_consumption' -ATTR_ENERGIE_COUNTER = 'energie_counter' +ATTR_ENERGIE_COUNTER = 'energie_counter_kwh' ATTR_PROFILE_MODE = 'profile_mode' @@ -29,13 +29,13 @@ async def async_setup_platform(hass, config, async_add_devices, async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the HomematicIP lights from a config entry.""" - from homematicip.device import ( - BrandSwitchMeasuring) + from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: - if isinstance(device, BrandSwitchMeasuring): + if isinstance(device, AsyncBrandSwitchMeasuring): devices.append(HomematicipLightMeasuring(home, device)) if devices: @@ -67,13 +67,15 @@ class HomematicipLightMeasuring(HomematicipLight): """MomematicIP measuring light device.""" @property - def current_power_w(self): - """Return the current power usage in W.""" - return self._device.currentPowerConsumption - - @property - def today_energy_kwh(self): - """Return the today total energy usage in kWh.""" - if self._device.energyCounter is None: - return 0 - return round(self._device.energyCounter) + def device_state_attributes(self): + """Return the state attributes of the generic device.""" + attr = super().device_state_attributes + if self._device.currentPowerConsumption > 0.05: + attr.update({ + ATTR_POWER_CONSUMPTION: + round(self._device.currentPowerConsumption, 2) + }) + attr.update({ + ATTR_ENERGIE_COUNTER: round(self._device.energyCounter, 2) + }) + return attr From ec3d2e97e8586cabfc4728016b43b9b521dfd32c Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 9 Jul 2018 10:04:51 +0100 Subject: [PATCH 073/147] fix camera.push API overwrite (#15334) * fix camera.push API overwrite * dont search in the component dictionary, but in hour own * remove error message * hound --- homeassistant/components/camera/push.py | 26 ++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/camera/push.py b/homeassistant/components/camera/push.py index fc4b18e26e40d2..def5c53dd3f06c 100644 --- a/homeassistant/components/camera/push.py +++ b/homeassistant/components/camera/push.py @@ -29,6 +29,8 @@ ATTR_FILENAME = 'filename' ATTR_LAST_TRIP = 'last_trip' +PUSH_CAMERA_DATA = 'push_camera' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_BUFFER_SIZE, default=1): cv.positive_int, @@ -41,11 +43,14 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Push Camera platform.""" + if PUSH_CAMERA_DATA not in hass.data: + hass.data[PUSH_CAMERA_DATA] = {} + cameras = [PushCamera(config[CONF_NAME], config[CONF_BUFFER_SIZE], config[CONF_TIMEOUT])] - hass.http.register_view(CameraPushReceiver(cameras, + hass.http.register_view(CameraPushReceiver(hass, config[CONF_IMAGE_FIELD])) async_add_devices(cameras) @@ -57,19 +62,18 @@ class CameraPushReceiver(HomeAssistantView): url = "/api/camera_push/{entity_id}" name = 'api:camera_push:camera_entity' - def __init__(self, cameras, image_field): + def __init__(self, hass, image_field): """Initialize CameraPushReceiver with camera entity.""" - self._cameras = cameras + self._cameras = hass.data[PUSH_CAMERA_DATA] self._image = image_field async def post(self, request, entity_id): """Accept the POST from Camera.""" - try: - (_camera,) = [camera for camera in self._cameras - if camera.entity_id == entity_id] - except ValueError: - _LOGGER.error("Unknown push camera %s", entity_id) - return self.json_message('Unknown Push Camera', + _camera = self._cameras.get(entity_id) + + if _camera is None: + _LOGGER.error("Unknown %s", entity_id) + return self.json_message('Unknown {}'.format(entity_id), HTTP_BAD_REQUEST) try: @@ -101,6 +105,10 @@ def __init__(self, name, buffer_size, timeout): self.queue = deque([], buffer_size) self._current_image = None + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.data[PUSH_CAMERA_DATA][self.entity_id] = self + @property def state(self): """Current state of the camera.""" From 287b1bce15aa7e19d4dc04acc62612974273ef65 Mon Sep 17 00:00:00 2001 From: Paul Klingelhuber Date: Mon, 9 Jul 2018 11:05:25 +0200 Subject: [PATCH 074/147] Add support for multi-channel enocean switches (D2-01-12 profile) (#14548) --- homeassistant/components/enocean.py | 7 +++++-- homeassistant/components/switch/enocean.py | 12 ++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 879f6a61899803..75e456f62bde4f 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -75,6 +75,7 @@ def callback(self, temp): _LOGGER.debug("Received radio packet: %s", temp) rxtype = None value = None + channel = 0 if temp.data[6] == 0x30: rxtype = "wallswitch" value = 1 @@ -84,8 +85,9 @@ def callback(self, temp): elif temp.data[4] == 0x0c: rxtype = "power" value = temp.data[3] + (temp.data[2] << 8) - elif temp.data[2] == 0x60: + elif temp.data[2] & 0x60 == 0x60: rxtype = "switch_status" + channel = temp.data[2] & 0x1F if temp.data[3] == 0xe4: value = 1 elif temp.data[3] == 0x80: @@ -104,7 +106,8 @@ def callback(self, temp): if temp.sender_int == self._combine_hex(device.dev_id): if value > 10: device.value_changed(1) - if rxtype == "switch_status" and device.stype == "switch": + if rxtype == "switch_status" and device.stype == "switch" and \ + channel == device.channel: if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "dimmerstatus" and device.stype == "dimmer": diff --git a/homeassistant/components/switch/enocean.py b/homeassistant/components/switch/enocean.py index abe197485d404d..986744aeec11b6 100644 --- a/homeassistant/components/switch/enocean.py +++ b/homeassistant/components/switch/enocean.py @@ -18,10 +18,12 @@ DEFAULT_NAME = 'EnOcean Switch' DEPENDENCIES = ['enocean'] +CONF_CHANNEL = 'channel' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CHANNEL, default=0): cv.positive_int, }) @@ -29,14 +31,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the EnOcean switch platform.""" dev_id = config.get(CONF_ID) devname = config.get(CONF_NAME) + channel = config.get(CONF_CHANNEL) - add_devices([EnOceanSwitch(dev_id, devname)]) + add_devices([EnOceanSwitch(dev_id, devname, channel)]) class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): """Representation of an EnOcean switch device.""" - def __init__(self, dev_id, devname): + def __init__(self, dev_id, devname, channel): """Initialize the EnOcean switch device.""" enocean.EnOceanDevice.__init__(self) self.dev_id = dev_id @@ -44,6 +47,7 @@ def __init__(self, dev_id, devname): self._light = None self._on_state = False self._on_state2 = False + self.channel = channel self.stype = "switch" @property @@ -61,7 +65,7 @@ def turn_on(self, **kwargs): optional = [0x03, ] optional.extend(self.dev_id) optional.extend([0xff, 0x00]) - self.send_command(data=[0xD2, 0x01, 0x00, 0x64, 0x00, + self.send_command(data=[0xD2, 0x01, self.channel & 0xFF, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00], optional=optional, packet_type=0x01) self._on_state = True @@ -71,7 +75,7 @@ def turn_off(self, **kwargs): optional = [0x03, ] optional.extend(self.dev_id) optional.extend([0xff, 0x00]) - self.send_command(data=[0xD2, 0x01, 0x00, 0x00, 0x00, + self.send_command(data=[0xD2, 0x01, self.channel & 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], optional=optional, packet_type=0x01) self._on_state = False From b9eb0081cd3a24b7d7a63fe6527204c44d0aee16 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 9 Jul 2018 11:39:41 +0200 Subject: [PATCH 075/147] Add sound mode support (#14910) * Add sound mode support * continuation line indent * indentation * indentation * Remove option to configure sound_mode_dict * Sound mode support - removed the sound_mode_raw propertie because it was not used, (still available through self._sound_mode_raw (as device attribute for automations and diagnostics) * Detect sound mode support from device Removed the config option to indicate if sound mode is supported. Added detection if sound mode is supported from the receiver itself. Pushed denonavr library to V.0.7.4 * Pushed denonavr to v.0.7.4 --- .../components/media_player/denonavr.py | 56 ++++++++++++++++--- requirements_all.txt | 2 +- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index ff0e4d907b11e5..2b2b9eb5c28196 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -12,19 +12,19 @@ from homeassistant.components.media_player import ( SUPPORT_PAUSE, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, - MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_TURN_ON, - MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) + SUPPORT_SELECT_SOURCE, SUPPORT_SELECT_SOUND_MODE, + SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, MediaPlayerDevice, + PLATFORM_SCHEMA, SUPPORT_TURN_ON, MEDIA_TYPE_MUSIC, + SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.7.3'] +REQUIREMENTS = ['denonavr==0.7.4'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False DEFAULT_TIMEOUT = 2 CONF_SHOW_ALL_SOURCES = 'show_all_sources' @@ -33,6 +33,8 @@ CONF_INVALID_ZONES_ERR = 'Invalid Zone (expected Zone2 or Zone3)' KEY_DENON_CACHE = 'denonavr_hosts' +ATTR_SOUND_MODE_RAW = 'sound_mode_raw' + SUPPORT_DENON = SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET @@ -146,6 +148,20 @@ def __init__(self, receiver): self._frequency = self._receiver.frequency self._station = self._receiver.station + self._sound_mode_support = self._receiver.support_sound_mode + if self._sound_mode_support: + self._sound_mode = self._receiver.sound_mode + self._sound_mode_raw = self._receiver.sound_mode_raw + self._sound_mode_list = self._receiver.sound_mode_list + else: + self._sound_mode = None + self._sound_mode_raw = None + self._sound_mode_list = None + + self._supported_features_base = SUPPORT_DENON + self._supported_features_base |= (self._sound_mode_support and + SUPPORT_SELECT_SOUND_MODE) + def update(self): """Get the latest status information from device.""" self._receiver.update() @@ -163,6 +179,9 @@ def update(self): self._band = self._receiver.band self._frequency = self._receiver.frequency self._station = self._receiver.station + if self._sound_mode_support: + self._sound_mode = self._receiver.sound_mode + self._sound_mode_raw = self._receiver.sound_mode_raw @property def name(self): @@ -196,12 +215,22 @@ def source_list(self): """Return a list of available input sources.""" return self._source_list + @property + def sound_mode(self): + """Return the current matched sound mode.""" + return self._sound_mode + + @property + def sound_mode_list(self): + """Return a list of available sound modes.""" + return self._sound_mode_list + @property def supported_features(self): """Flag media player features that are supported.""" if self._current_source in self._receiver.netaudio_func_list: - return SUPPORT_DENON | SUPPORT_MEDIA_MODES - return SUPPORT_DENON + return self._supported_features_base | SUPPORT_MEDIA_MODES + return self._supported_features_base @property def media_content_id(self): @@ -275,6 +304,15 @@ def media_episode(self): """Episode of current playing media, TV show only.""" return None + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if (self._sound_mode_raw is not None and self._sound_mode_support and + self._power == 'ON'): + attributes[ATTR_SOUND_MODE_RAW] = self._sound_mode_raw + return attributes + def media_play_pause(self): """Simulate play pause media player.""" return self._receiver.toggle_play_pause() @@ -291,6 +329,10 @@ def select_source(self, source): """Select input source.""" return self._receiver.set_input_func(source) + def select_sound_mode(self, sound_mode): + """Select sound mode.""" + return self._receiver.set_sound_mode(sound_mode) + def turn_on(self): """Turn on media player.""" if self._receiver.power_on(): diff --git a/requirements_all.txt b/requirements_all.txt index c84f484cabd8f2..42d5c84c868bde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -260,7 +260,7 @@ defusedxml==0.5.0 deluge-client==1.4.0 # homeassistant.components.media_player.denonavr -denonavr==0.7.3 +denonavr==0.7.4 # homeassistant.components.media_player.directv directpy==0.5 From 1d1408b98d3612f24482e53f5069bbfe9ed00051 Mon Sep 17 00:00:00 2001 From: iliketoprogram14 Date: Mon, 9 Jul 2018 02:44:50 -0700 Subject: [PATCH 076/147] Fixed issue 15340. alexa/smart_home module can now skip properties that aren't supported in the current state, eg lowerSetpoint in Heat mode or targetSetpoint in Eco mode for Nest devices. (#15352) --- homeassistant/components/alexa/smart_home.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index ff2d4adf30dc38..9b7da71a293192 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -270,11 +270,14 @@ def serialize_properties(self): """Return properties serialized for an API response.""" for prop in self.properties_supported(): prop_name = prop['name'] - yield { - 'name': prop_name, - 'namespace': self.name(), - 'value': self.get_property(prop_name), - } + # pylint: disable=assignment-from-no-return + prop_value = self.get_property(prop_name) + if prop_value is not None: + yield { + 'name': prop_name, + 'namespace': self.name(), + 'value': prop_value, + } class _AlexaPowerController(_AlexaInterface): @@ -438,14 +441,17 @@ def get_property(self, name): unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT] temp = None if name == 'targetSetpoint': - temp = self.entity.attributes.get(ATTR_TEMPERATURE) + temp = self.entity.attributes.get(climate.ATTR_TEMPERATURE) elif name == 'lowerSetpoint': temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) elif name == 'upperSetpoint': temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) - if temp is None: + else: raise _UnsupportedProperty(name) + if temp is None: + return None + return { 'value': float(temp), 'scale': API_TEMP_UNITS[unit], From f7d7d825b042fa38c558061610c98a0a1295fa15 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 9 Jul 2018 14:39:28 +0200 Subject: [PATCH 077/147] Efergy (#15380) * Update format * Use string formatting --- homeassistant/components/sensor/efergy.py | 60 ++++++++++--------- tests/components/sensor/test_efergy.py | 72 ++++++++++++----------- tests/fixtures/efergy_budget.json | 5 +- tests/fixtures/efergy_cost.json | 6 +- tests/fixtures/efergy_energy.json | 6 +- 5 files changed, 83 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index c14a33dce01736..b9fe294146315d 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -5,12 +5,13 @@ https://home-assistant.io/components/sensor.efergy/ """ import logging -import voluptuous as vol -from requests import RequestException, get +import requests +import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_CURRENCY +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,6 @@ CONF_MONITORED_VARIABLES = 'monitored_variables' CONF_SENSOR_TYPE = 'type' -CONF_CURRENCY = 'currency' CONF_PERIOD = 'period' CONF_INSTANT = 'instant_readings' @@ -60,17 +60,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Efergy sensor.""" app_token = config.get(CONF_APPTOKEN) utc_offset = str(config.get(CONF_UTC_OFFSET)) + dev = [] for variable in config[CONF_MONITORED_VARIABLES]: if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES: - url_string = _RESOURCE + 'getCurrentValuesSummary?token=' \ - + app_token - response = get(url_string, timeout=10) + url_string = '{}getCurrentValuesSummary?token={}'.format( + _RESOURCE, app_token) + response = requests.get(url_string, timeout=10) for sensor in response.json(): sid = sensor['sid'] - dev.append(EfergySensor(variable[CONF_SENSOR_TYPE], app_token, - utc_offset, variable[CONF_PERIOD], - variable[CONF_CURRENCY], sid)) + dev.append(EfergySensor( + variable[CONF_SENSOR_TYPE], app_token, utc_offset, + variable[CONF_PERIOD], variable[CONF_CURRENCY], sid)) dev.append(EfergySensor( variable[CONF_SENSOR_TYPE], app_token, utc_offset, variable[CONF_PERIOD], variable[CONF_CURRENCY])) @@ -86,7 +87,7 @@ def __init__(self, sensor_type, app_token, utc_offset, period, """Initialize the sensor.""" self.sid = sid if sid: - self._name = 'efergy_' + sid + self._name = 'efergy_{}'.format(sid) else: self._name = SENSOR_TYPES[sensor_type][0] self.type = sensor_type @@ -96,7 +97,8 @@ def __init__(self, sensor_type, app_token, utc_offset, period, self.period = period self.currency = currency if self.type == 'cost': - self._unit_of_measurement = self.currency + '/' + self.period + self._unit_of_measurement = '{}/{}'.format( + self.currency, self.period) else: self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -119,34 +121,34 @@ def update(self): """Get the Efergy monitor data from the web service.""" try: if self.type == 'instant_readings': - url_string = _RESOURCE + 'getInstant?token=' + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getInstant?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) self._state = response.json()['reading'] elif self.type == 'amount': - url_string = _RESOURCE + 'getEnergy?token=' + self.app_token \ - + '&offset=' + self.utc_offset + '&period=' \ - + self.period - response = get(url_string, timeout=10) + url_string = '{}getEnergy?token={}&offset={}&period={}'.format( + _RESOURCE, self.app_token, self.utc_offset, self.period) + response = requests.get(url_string, timeout=10) self._state = response.json()['sum'] elif self.type == 'budget': - url_string = _RESOURCE + 'getBudget?token=' + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getBudget?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) self._state = response.json()['status'] elif self.type == 'cost': - url_string = _RESOURCE + 'getCost?token=' + self.app_token \ - + '&offset=' + self.utc_offset + '&period=' \ - + self.period - response = get(url_string, timeout=10) + url_string = '{}getCost?token={}&offset={}&period={}'.format( + _RESOURCE, self.app_token, self.utc_offset, self.period) + response = requests.get(url_string, timeout=10) self._state = response.json()['sum'] elif self.type == 'current_values': - url_string = _RESOURCE + 'getCurrentValuesSummary?token=' \ - + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getCurrentValuesSummary?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) for sensor in response.json(): if self.sid == sensor['sid']: measurement = next(iter(sensor['data'][0].values())) self._state = measurement else: - self._state = 'Unknown' - except (RequestException, ValueError, KeyError): + self._state = None + except (requests.RequestException, ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) diff --git a/tests/components/sensor/test_efergy.py b/tests/components/sensor/test_efergy.py index 83309329a11d97..9a79ab5b81c473 100644 --- a/tests/components/sensor/test_efergy.py +++ b/tests/components/sensor/test_efergy.py @@ -14,21 +14,20 @@ 'platform': 'efergy', 'app_token': token, 'utc_offset': '300', - 'monitored_variables': [{'type': 'amount', 'period': 'day'}, - {'type': 'instant_readings'}, - {'type': 'budget'}, - {'type': 'cost', 'period': 'day', 'currency': '$'}, - {'type': 'current_values'} - ] + 'monitored_variables': [ + {'type': 'amount', 'period': 'day'}, + {'type': 'instant_readings'}, + {'type': 'budget'}, + {'type': 'cost', 'period': 'day', 'currency': '$'}, + {'type': 'current_values'}, + ] } MULTI_SENSOR_CONFIG = { 'platform': 'efergy', 'app_token': multi_sensor_token, 'utc_offset': '300', - 'monitored_variables': [ - {'type': 'current_values'} - ] + 'monitored_variables': [{'type': 'current_values'}], } @@ -36,22 +35,23 @@ def mock_responses(mock): """Mock responses for Efergy.""" base_url = 'https://engage.efergy.com/mobile_proxy/' mock.get( - base_url + 'getInstant?token=' + token, + '{}getInstant?token={}'.format(base_url, token), text=load_fixture('efergy_instant.json')) mock.get( - base_url + 'getEnergy?token=' + token + '&offset=300&period=day', + '{}getEnergy?token={}&offset=300&period=day'.format(base_url, token), text=load_fixture('efergy_energy.json')) mock.get( - base_url + 'getBudget?token=' + token, + '{}getBudget?token={}'.format(base_url, token), text=load_fixture('efergy_budget.json')) mock.get( - base_url + 'getCost?token=' + token + '&offset=300&period=day', + '{}getCost?token={}&offset=300&period=day'.format(base_url, token), text=load_fixture('efergy_cost.json')) mock.get( - base_url + 'getCurrentValuesSummary?token=' + token, + '{}getCurrentValuesSummary?token={}'.format(base_url, token), text=load_fixture('efergy_current_values_single.json')) mock.get( - base_url + 'getCurrentValuesSummary?token=' + multi_sensor_token, + '{}getCurrentValuesSummary?token={}'.format( + base_url, multi_sensor_token), text=load_fixture('efergy_current_values_multi.json')) @@ -69,7 +69,7 @@ def add_devices(self, devices, mock): self.DEVICES.append(device) def setUp(self): - """Initialize values for this testcase class.""" + """Initialize values for this test case class.""" self.hass = get_test_home_assistant() self.config = ONE_SENSOR_CONFIG @@ -82,27 +82,31 @@ def test_single_sensor_readings(self, mock): """Test for successfully setting up the Efergy platform.""" mock_responses(mock) assert setup_component(self.hass, 'sensor', { - 'sensor': ONE_SENSOR_CONFIG}) - self.assertEqual('38.21', - self.hass.states.get('sensor.energy_consumed').state) - self.assertEqual('1580', - self.hass.states.get('sensor.energy_usage').state) - self.assertEqual('ok', - self.hass.states.get('sensor.energy_budget').state) - self.assertEqual('5.27', - self.hass.states.get('sensor.energy_cost').state) - self.assertEqual('1628', - self.hass.states.get('sensor.efergy_728386').state) + 'sensor': ONE_SENSOR_CONFIG, + }) + + self.assertEqual( + '38.21', self.hass.states.get('sensor.energy_consumed').state) + self.assertEqual( + '1580', self.hass.states.get('sensor.energy_usage').state) + self.assertEqual( + 'ok', self.hass.states.get('sensor.energy_budget').state) + self.assertEqual( + '5.27', self.hass.states.get('sensor.energy_cost').state) + self.assertEqual( + '1628', self.hass.states.get('sensor.efergy_728386').state) @requests_mock.Mocker() def test_multi_sensor_readings(self, mock): """Test for multiple sensors in one household.""" mock_responses(mock) assert setup_component(self.hass, 'sensor', { - 'sensor': MULTI_SENSOR_CONFIG}) - self.assertEqual('218', - self.hass.states.get('sensor.efergy_728386').state) - self.assertEqual('1808', - self.hass.states.get('sensor.efergy_0').state) - self.assertEqual('312', - self.hass.states.get('sensor.efergy_728387').state) + 'sensor': MULTI_SENSOR_CONFIG, + }) + + self.assertEqual( + '218', self.hass.states.get('sensor.efergy_728386').state) + self.assertEqual( + '1808', self.hass.states.get('sensor.efergy_0').state) + self.assertEqual( + '312', self.hass.states.get('sensor.efergy_728387').state) diff --git a/tests/fixtures/efergy_budget.json b/tests/fixtures/efergy_budget.json index 2b0a64fbae551d..73fc9b549b6ed4 100644 --- a/tests/fixtures/efergy_budget.json +++ b/tests/fixtures/efergy_budget.json @@ -1 +1,4 @@ -{"status":"ok", "monthly_budget":250.0000} \ No newline at end of file +{ + "status": "ok", + "monthly_budget": 250.0000 +} \ No newline at end of file diff --git a/tests/fixtures/efergy_cost.json b/tests/fixtures/efergy_cost.json index 8b2ccfff18a815..41150a30e87dcc 100644 --- a/tests/fixtures/efergy_cost.json +++ b/tests/fixtures/efergy_cost.json @@ -1 +1,5 @@ -{"sum":"5.27","duration":70320,"units":"GBP"} \ No newline at end of file +{ + "sum": "5.27", + "duration": 70320, + "units": "GBP" +} \ No newline at end of file diff --git a/tests/fixtures/efergy_energy.json b/tests/fixtures/efergy_energy.json index 4033ad074a622e..f1c1ce248beb48 100644 --- a/tests/fixtures/efergy_energy.json +++ b/tests/fixtures/efergy_energy.json @@ -1 +1,5 @@ -{"sum":"38.21","duration":70320,"units":"kWh"} \ No newline at end of file +{ + "sum": "38.21", + "duration": 70320, + "units": "kWh" +} \ No newline at end of file From 0d4841cbea44b3d100c6c20aea5dccd8772d9fe9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Jul 2018 18:24:46 +0200 Subject: [PATCH 078/147] Use IndieAuth for client ID (#15369) * Use IndieAuth for client ID * Lint * Lint & Fix tests * Allow local IP addresses * Update comment --- homeassistant/auth.py | 91 +----------- homeassistant/components/auth/__init__.py | 59 ++++---- homeassistant/components/auth/client.py | 79 ----------- homeassistant/components/auth/indieauth.py | 130 ++++++++++++++++++ homeassistant/components/frontend/__init__.py | 19 +-- tests/common.py | 4 +- tests/components/auth/__init__.py | 9 -- tests/components/auth/test_client.py | 70 ---------- tests/components/auth/test_indieauth.py | 110 +++++++++++++++ tests/components/auth/test_init.py | 16 ++- tests/components/auth/test_init_link_user.py | 19 ++- tests/components/auth/test_init_login_flow.py | 23 ++-- tests/components/conftest.py | 9 +- tests/test_auth.py | 38 ++--- 14 files changed, 329 insertions(+), 347 deletions(-) delete mode 100644 homeassistant/components/auth/client.py create mode 100644 homeassistant/components/auth/indieauth.py delete mode 100644 tests/components/auth/test_client.py create mode 100644 tests/components/auth/test_indieauth.py diff --git a/homeassistant/auth.py b/homeassistant/auth.py index e6760cd9096bc2..ae191f24c61ffd 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -186,16 +186,6 @@ class Credentials: is_new = attr.ib(type=bool, default=True) -@attr.s(slots=True) -class Client: - """Client that interacts with Home Assistant on behalf of a user.""" - - name = attr.ib(type=str) - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - secret = attr.ib(type=str, default=attr.Factory(generate_secret)) - redirect_uris = attr.ib(type=list, default=attr.Factory(list)) - - async def load_auth_provider_module(hass, provider): """Load an auth provider.""" try: @@ -356,20 +346,20 @@ async def async_remove_user(self, user): """Remove a user.""" await self._store.async_remove_user(user) - async def async_create_refresh_token(self, user, client=None): + async def async_create_refresh_token(self, user, client_id=None): """Create a new refresh token for a user.""" if not user.is_active: raise ValueError('User is not active') - if user.system_generated and client is not None: + if user.system_generated and client_id is not None: raise ValueError( 'System generated users cannot have refresh tokens connected ' 'to a client.') - if not user.system_generated and client is None: + if not user.system_generated and client_id is None: raise ValueError('Client is required to generate a refresh token.') - return await self._store.async_create_refresh_token(user, client) + return await self._store.async_create_refresh_token(user, client_id) async def async_get_refresh_token(self, token): """Get refresh token by token.""" @@ -396,26 +386,6 @@ def async_get_access_token(self, token): return tkn - async def async_create_client(self, name, *, redirect_uris=None, - no_secret=False): - """Create a new client.""" - return await self._store.async_create_client( - name, redirect_uris, no_secret) - - async def async_get_or_create_client(self, name, *, redirect_uris=None, - no_secret=False): - """Find a client, if not exists, create a new one.""" - for client in await self._store.async_get_clients(): - if client.name == name: - return client - - return await self._store.async_create_client( - name, redirect_uris, no_secret) - - async def async_get_client(self, client_id): - """Get a client.""" - return await self._store.async_get_client(client_id) - async def _async_create_login_flow(self, handler, *, source, data): """Create a login flow.""" auth_provider = self._providers[handler] @@ -456,7 +426,6 @@ def __init__(self, hass): """Initialize the auth store.""" self.hass = hass self._users = None - self._clients = None self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) async def async_get_users(self): @@ -515,9 +484,8 @@ async def async_remove_user(self, user): self._users.pop(user.id) await self.async_save() - async def async_create_refresh_token(self, user, client=None): + async def async_create_refresh_token(self, user, client_id=None): """Create a new token for a user.""" - client_id = client.id if client is not None else None refresh_token = RefreshToken(user=user, client_id=client_id) user.refresh_tokens[refresh_token.token] = refresh_token await self.async_save() @@ -535,38 +503,6 @@ async def async_get_refresh_token(self, token): return None - async def async_create_client(self, name, redirect_uris, no_secret): - """Create a new client.""" - if self._clients is None: - await self.async_load() - - kwargs = { - 'name': name, - 'redirect_uris': redirect_uris - } - - if no_secret: - kwargs['secret'] = None - - client = Client(**kwargs) - self._clients[client.id] = client - await self.async_save() - return client - - async def async_get_clients(self): - """Return all clients.""" - if self._clients is None: - await self.async_load() - - return list(self._clients.values()) - - async def async_get_client(self, client_id): - """Get a client.""" - if self._clients is None: - await self.async_load() - - return self._clients.get(client_id) - async def async_load(self): """Load the users.""" data = await self._store.async_load() @@ -578,7 +514,6 @@ async def async_load(self): if data is None: self._users = {} - self._clients = {} return users = { @@ -618,12 +553,7 @@ async def async_load(self): ) refresh_token.access_tokens.append(token) - clients = { - cl_dict['id']: Client(**cl_dict) for cl_dict in data['clients'] - } - self._users = users - self._clients = clients async def async_save(self): """Save users.""" @@ -676,19 +606,8 @@ async def async_save(self): for access_token in refresh_token.access_tokens ] - clients = [ - { - 'id': client.id, - 'name': client.name, - 'secret': client.secret, - 'redirect_uris': client.redirect_uris, - } - for client in self._clients.values() - ] - data = { 'users': users, - 'clients': clients, 'credentials': credentials, 'access_tokens': access_tokens, 'refresh_tokens': refresh_tokens, diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 511999c52abaff..c41b417576e990 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -115,7 +115,8 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -from .client import verify_client +from . import indieauth + DOMAIN = 'auth' DEPENDENCIES = ['http'] @@ -143,8 +144,7 @@ class AuthProvidersView(HomeAssistantView): name = 'api:auth:providers' requires_auth = False - @verify_client - async def get(self, request, client): + async def get(self, request): """Get available auth providers.""" return self.json([{ 'name': provider.name, @@ -164,16 +164,16 @@ async def get(self, request): """Do not allow index of flows in progress.""" return aiohttp.web.Response(status=405) - # pylint: disable=arguments-differ - @verify_client @RequestDataValidator(vol.Schema({ + vol.Required('client_id'): str, vol.Required('handler'): vol.Any(str, list), vol.Required('redirect_uri'): str, })) - async def post(self, request, client, data): + async def post(self, request, data): """Create a new login flow.""" - if data['redirect_uri'] not in client.redirect_uris: - return self.json_message('invalid redirect uri', ) + if not indieauth.verify_redirect_uri(data['client_id'], + data['redirect_uri']): + return self.json_message('invalid client id or redirect uri', 400) # pylint: disable=no-value-for-parameter return await super().post(request) @@ -191,16 +191,20 @@ def __init__(self, flow_mgr, store_credentials): super().__init__(flow_mgr) self._store_credentials = store_credentials - # pylint: disable=arguments-differ - async def get(self, request): + async def get(self, request, flow_id): """Do not allow getting status of a flow in progress.""" return self.json_message('Invalid flow specified', 404) - # pylint: disable=arguments-differ - @verify_client - @RequestDataValidator(vol.Schema(dict), allow_empty=True) - async def post(self, request, client, flow_id, data): + @RequestDataValidator(vol.Schema({ + 'client_id': str + }, extra=vol.ALLOW_EXTRA)) + async def post(self, request, flow_id, data): """Handle progressing a login flow request.""" + client_id = data.pop('client_id') + + if not indieauth.verify_client_id(client_id): + return self.json_message('Invalid client id', 400) + try: result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: @@ -212,7 +216,7 @@ async def post(self, request, client, flow_id, data): return self.json(self._prepare_result_json(result)) result.pop('data') - result['result'] = self._store_credentials(client.id, result['result']) + result['result'] = self._store_credentials(client_id, result['result']) return self.json(result) @@ -228,24 +232,31 @@ def __init__(self, retrieve_credentials): """Initialize the grant token view.""" self._retrieve_credentials = retrieve_credentials - @verify_client - async def post(self, request, client): + async def post(self, request): """Grant a token.""" hass = request.app['hass'] data = await request.post() + + client_id = data.get('client_id') + if client_id is None or not indieauth.verify_client_id(client_id): + return self.json({ + 'error': 'invalid_request', + }, status_code=400) + grant_type = data.get('grant_type') if grant_type == 'authorization_code': - return await self._async_handle_auth_code(hass, client, data) + return await self._async_handle_auth_code(hass, client_id, data) elif grant_type == 'refresh_token': - return await self._async_handle_refresh_token(hass, client, data) + return await self._async_handle_refresh_token( + hass, client_id, data) return self.json({ 'error': 'unsupported_grant_type', }, status_code=400) - async def _async_handle_auth_code(self, hass, client, data): + async def _async_handle_auth_code(self, hass, client_id, data): """Handle authorization code request.""" code = data.get('code') @@ -254,7 +265,7 @@ async def _async_handle_auth_code(self, hass, client, data): 'error': 'invalid_request', }, status_code=400) - credentials = self._retrieve_credentials(client.id, code) + credentials = self._retrieve_credentials(client_id, code) if credentials is None: return self.json({ @@ -263,7 +274,7 @@ async def _async_handle_auth_code(self, hass, client, data): user = await hass.auth.async_get_or_create_user(credentials) refresh_token = await hass.auth.async_create_refresh_token(user, - client) + client_id) access_token = hass.auth.async_create_access_token(refresh_token) return self.json({ @@ -274,7 +285,7 @@ async def _async_handle_auth_code(self, hass, client, data): int(refresh_token.access_token_expiration.total_seconds()), }) - async def _async_handle_refresh_token(self, hass, client, data): + async def _async_handle_refresh_token(self, hass, client_id, data): """Handle authorization code request.""" token = data.get('refresh_token') @@ -285,7 +296,7 @@ async def _async_handle_refresh_token(self, hass, client, data): refresh_token = await hass.auth.async_get_refresh_token(token) - if refresh_token is None or refresh_token.client_id != client.id: + if refresh_token is None or refresh_token.client_id != client_id: return self.json({ 'error': 'invalid_grant', }, status_code=400) diff --git a/homeassistant/components/auth/client.py b/homeassistant/components/auth/client.py deleted file mode 100644 index 122c3032188227..00000000000000 --- a/homeassistant/components/auth/client.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Helpers to resolve client ID/secret.""" -import base64 -from functools import wraps -import hmac - -import aiohttp.hdrs - - -def verify_client(method): - """Decorator to verify client id/secret on requests.""" - @wraps(method) - async def wrapper(view, request, *args, **kwargs): - """Verify client id/secret before doing request.""" - client = await _verify_client(request) - - if client is None: - return view.json({ - 'error': 'invalid_client', - }, status_code=401) - - return await method( - view, request, *args, **kwargs, client=client) - - return wrapper - - -async def _verify_client(request): - """Method to verify the client id/secret in consistent time. - - By using a consistent time for looking up client id and comparing the - secret, we prevent attacks by malicious actors trying different client ids - and are able to derive from the time it takes to process the request if - they guessed the client id correctly. - """ - if aiohttp.hdrs.AUTHORIZATION not in request.headers: - return None - - auth_type, auth_value = \ - request.headers.get(aiohttp.hdrs.AUTHORIZATION).split(' ', 1) - - if auth_type != 'Basic': - return None - - decoded = base64.b64decode(auth_value).decode('utf-8') - try: - client_id, client_secret = decoded.split(':', 1) - except ValueError: - # If no ':' in decoded - client_id, client_secret = decoded, None - - return await async_secure_get_client( - request.app['hass'], client_id, client_secret) - - -async def async_secure_get_client(hass, client_id, client_secret): - """Get a client id/secret in consistent time.""" - client = await hass.auth.async_get_client(client_id) - - if client is None: - if client_secret is not None: - # Still do a compare so we run same time as if a client was found. - hmac.compare_digest(client_secret.encode('utf-8'), - client_secret.encode('utf-8')) - return None - - if client.secret is None: - return client - - elif client_secret is None: - # Still do a compare so we run same time as if a secret was passed. - hmac.compare_digest(client.secret.encode('utf-8'), - client.secret.encode('utf-8')) - return None - - elif hmac.compare_digest(client_secret.encode('utf-8'), - client.secret.encode('utf-8')): - return client - - return None diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py new file mode 100644 index 00000000000000..ef7f8a9b2927f1 --- /dev/null +++ b/homeassistant/components/auth/indieauth.py @@ -0,0 +1,130 @@ +"""Helpers to resolve client ID/secret.""" +from ipaddress import ip_address, ip_network +from urllib.parse import urlparse + +# IP addresses of loopback interfaces +ALLOWED_IPS = ( + ip_address('127.0.0.1'), + ip_address('::1'), +) + +# RFC1918 - Address allocation for Private Internets +ALLOWED_NETWORKS = ( + ip_network('10.0.0.0/8'), + ip_network('172.16.0.0/12'), + ip_network('192.168.0.0/16'), +) + + +def verify_redirect_uri(client_id, redirect_uri): + """Verify that the client and redirect uri match.""" + try: + client_id_parts = _parse_client_id(client_id) + except ValueError: + return False + + redirect_parts = _parse_url(redirect_uri) + + # IndieAuth 4.2.2 allows for redirect_uri to be on different domain + # but needs to be specified in link tag when fetching `client_id`. + # This is not implemented. + + # Verify redirect url and client url have same scheme and domain. + return ( + client_id_parts.scheme == redirect_parts.scheme and + client_id_parts.netloc == redirect_parts.netloc + ) + + +def verify_client_id(client_id): + """Verify that the client id is valid.""" + try: + _parse_client_id(client_id) + return True + except ValueError: + return False + + +def _parse_url(url): + """Parse a url in parts and canonicalize according to IndieAuth.""" + parts = urlparse(url) + + # Canonicalize a url according to IndieAuth 3.2. + + # SHOULD convert the hostname to lowercase + parts = parts._replace(netloc=parts.netloc.lower()) + + # If a URL with no path component is ever encountered, + # it MUST be treated as if it had the path /. + if parts.path == '': + parts = parts._replace(path='/') + + return parts + + +def _parse_client_id(client_id): + """Test if client id is a valid URL according to IndieAuth section 3.2. + + https://indieauth.spec.indieweb.org/#client-identifier + """ + parts = _parse_url(client_id) + + # Client identifier URLs + # MUST have either an https or http scheme + if parts.scheme not in ('http', 'https'): + raise ValueError() + + # MUST contain a path component + # Handled by url canonicalization. + + # MUST NOT contain single-dot or double-dot path segments + if any(segment in ('.', '..') for segment in parts.path.split('/')): + raise ValueError( + 'Client ID cannot contain single-dot or double-dot path segments') + + # MUST NOT contain a fragment component + if parts.fragment != '': + raise ValueError('Client ID cannot contain a fragment') + + # MUST NOT contain a username or password component + if parts.username is not None: + raise ValueError('Client ID cannot contain username') + + if parts.password is not None: + raise ValueError('Client ID cannot contain password') + + # MAY contain a port + try: + # parts raises ValueError when port cannot be parsed as int + parts.port + except ValueError: + raise ValueError('Client ID contains invalid port') + + # Additionally, hostnames + # MUST be domain names or a loopback interface and + # MUST NOT be IPv4 or IPv6 addresses except for IPv4 127.0.0.1 + # or IPv6 [::1] + + # We are not goint to follow the spec here. We are going to allow + # any internal network IP to be used inside a client id. + + address = None + + try: + netloc = parts.netloc + + # Strip the [, ] from ipv6 addresses before parsing + if netloc[0] == '[' and netloc[-1] == ']': + netloc = netloc[1:-1] + + address = ip_address(netloc) + except ValueError: + # Not an ip address + pass + + if (address is None or + address in ALLOWED_IPS or + any(address in network for network in ALLOWED_NETWORKS)): + return parts + + raise ValueError('Hostname should be a domain name or local IP address') diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a6fb8735a668f9..4304742021f88a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -200,15 +200,6 @@ def add_manifest_json_key(key, val): async def async_setup(hass, config): """Set up the serving of the frontend.""" - if hass.auth.active: - client = await hass.auth.async_get_or_create_client( - 'Home Assistant Frontend', - redirect_uris=['/'], - no_secret=True, - ) - else: - client = None - hass.components.websocket_api.async_register_command( WS_TYPE_GET_PANELS, websocket_get_panels, SCHEMA_GET_PANELS) hass.components.websocket_api.async_register_command( @@ -255,7 +246,7 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path, js_version, client) + index_view = IndexView(repo_path, js_version, hass.auth.active) hass.http.register_view(index_view) @callback @@ -350,11 +341,11 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self, repo_path, js_option, client): + def __init__(self, repo_path, js_option, auth_active): """Initialize the frontend view.""" self.repo_path = repo_path self.js_option = js_option - self.client = client + self.auth_active = auth_active self._template_cache = {} def get_template(self, latest): @@ -399,11 +390,9 @@ async def get(self, request, extra=None): no_auth=no_auth, theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[extra_key], + client_id=self.auth_active ) - if self.client is not None: - template_params['client_id'] = self.client.id - return web.Response(text=template.render(**template_params), content_type='text/html') diff --git a/tests/common.py b/tests/common.py index ccb8f49ea97923..98a3b0a6074e2b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -31,6 +31,8 @@ _TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) INSTANCES = [] +CLIENT_ID = 'https://example.com/app' +CLIENT_REDIRECT_URI = 'https://example.com/app/callback' def threadsafe_callback_factory(func): @@ -330,8 +332,6 @@ def add_to_auth_manager(self, auth_mgr): def ensure_auth_manager_loaded(auth_mgr): """Ensure an auth manager is considered loaded.""" store = auth_mgr._store - if store._clients is None: - store._clients = {} if store._users is None: store._users = {} diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index 21719c12569b3b..ce94d1ecbfadaf 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -1,6 +1,4 @@ """Tests for the auth component.""" -from aiohttp.helpers import BasicAuth - from homeassistant import auth from homeassistant.setup import async_setup_component @@ -16,10 +14,6 @@ 'name': 'Test Name' }] }] -CLIENT_ID = 'test-id' -CLIENT_SECRET = 'test-secret' -CLIENT_AUTH = BasicAuth(CLIENT_ID, CLIENT_SECRET) -CLIENT_REDIRECT_URI = 'http://example.com/callback' async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, @@ -32,9 +26,6 @@ async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, 'api_password': 'bla' } }) - client = auth.Client('Test Client', CLIENT_ID, CLIENT_SECRET, - redirect_uris=[CLIENT_REDIRECT_URI]) - hass.auth._store._clients[client.id] = client if setup_api: await async_setup_component(hass, 'api', {}) return await aiohttp_client(hass.http.app) diff --git a/tests/components/auth/test_client.py b/tests/components/auth/test_client.py deleted file mode 100644 index 65ad22efae2c11..00000000000000 --- a/tests/components/auth/test_client.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for the client validator.""" -from aiohttp.helpers import BasicAuth -import pytest - -from homeassistant.setup import async_setup_component -from homeassistant.components.auth.client import verify_client -from homeassistant.components.http.view import HomeAssistantView - -from . import async_setup_auth - - -@pytest.fixture -def mock_view(hass): - """Register a view that verifies client id/secret.""" - hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) - - clients = [] - - class ClientView(HomeAssistantView): - url = '/' - name = 'bla' - - @verify_client - async def get(self, request, client): - """Handle GET request.""" - clients.append(client) - - hass.http.register_view(ClientView) - return clients - - -async def test_verify_client(hass, aiohttp_client, mock_view): - """Test that verify client can extract client auth from a request.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth(client.id, client.secret)) - assert resp.status == 200 - assert mock_view[0] is client - - -async def test_verify_client_no_auth_header(hass, aiohttp_client, mock_view): - """Test that verify client will decline unknown client id.""" - http_client = await async_setup_auth(hass, aiohttp_client) - - resp = await http_client.get('/') - assert resp.status == 401 - assert mock_view == [] - - -async def test_verify_client_invalid_client_id(hass, aiohttp_client, - mock_view): - """Test that verify client will decline unknown client id.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth('invalid', client.secret)) - assert resp.status == 401 - assert mock_view == [] - - -async def test_verify_client_invalid_client_secret(hass, aiohttp_client, - mock_view): - """Test that verify client will decline incorrect client secret.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth(client.id, 'invalid')) - assert resp.status == 401 - assert mock_view == [] diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py new file mode 100644 index 00000000000000..7bd720ddf70033 --- /dev/null +++ b/tests/components/auth/test_indieauth.py @@ -0,0 +1,110 @@ +"""Tests for the client validator.""" +from homeassistant.components.auth import indieauth + +import pytest + + +def test_client_id_scheme(): + """Test we enforce valid scheme.""" + assert indieauth._parse_client_id('http://ex.com/') + assert indieauth._parse_client_id('https://ex.com/') + + with pytest.raises(ValueError): + indieauth._parse_client_id('ftp://ex.com') + + +def test_client_id_path(): + """Test we enforce valid path.""" + assert indieauth._parse_client_id('http://ex.com').path == '/' + assert indieauth._parse_client_id('http://ex.com/hello').path == '/hello' + assert indieauth._parse_client_id( + 'http://ex.com/hello/.world').path == '/hello/.world' + assert indieauth._parse_client_id( + 'http://ex.com/hello./.world').path == '/hello./.world' + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/.') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/hello/./yo') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/hello/../yo') + + +def test_client_id_fragment(): + """Test we enforce valid fragment.""" + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/#yoo') + + +def test_client_id_user_pass(): + """Test we enforce valid username/password.""" + with pytest.raises(ValueError): + indieauth._parse_client_id('http://user@ex.com/') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://user:pass@ex.com/') + + +def test_client_id_hostname(): + """Test we enforce valid hostname.""" + assert indieauth._parse_client_id('http://www.home-assistant.io/') + assert indieauth._parse_client_id('http://[::1]') + assert indieauth._parse_client_id('http://127.0.0.1') + assert indieauth._parse_client_id('http://10.0.0.0') + assert indieauth._parse_client_id('http://10.255.255.255') + assert indieauth._parse_client_id('http://172.16.0.0') + assert indieauth._parse_client_id('http://172.31.255.255') + assert indieauth._parse_client_id('http://192.168.0.0') + assert indieauth._parse_client_id('http://192.168.255.255') + + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://255.255.255.255/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://11.0.0.0/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://172.32.0.0/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://192.167.0.0/') + + +def test_parse_url_lowercase_host(): + """Test we update empty paths.""" + assert indieauth._parse_url('http://ex.com/hello').path == '/hello' + assert indieauth._parse_url('http://EX.COM/hello').hostname == 'ex.com' + + parts = indieauth._parse_url('http://EX.COM:123/HELLO') + assert parts.netloc == 'ex.com:123' + assert parts.path == '/HELLO' + + +def test_parse_url_path(): + """Test we update empty paths.""" + assert indieauth._parse_url('http://ex.com').path == '/' + + +def test_verify_redirect_uri(): + """Test that we verify redirect uri correctly.""" + assert indieauth.verify_redirect_uri( + 'http://ex.com', + 'http://ex.com/callback' + ) + + # Different domain + assert not indieauth.verify_redirect_uri( + 'http://ex.com', + 'http://different.com/callback' + ) + + # Different scheme + assert not indieauth.verify_redirect_uri( + 'http://ex.com', + 'https://ex.com/callback' + ) + + # Different subdomain + assert not indieauth.verify_redirect_uri( + 'https://sub1.ex.com', + 'https://sub2.ex.com/callback' + ) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 7cff04327b85c8..68a77d18d56d9e 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -1,22 +1,26 @@ """Integration tests for the auth component.""" -from . import async_setup_auth, CLIENT_AUTH, CLIENT_REDIRECT_URI +from . import async_setup_auth + +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI async def test_login_new_user_and_refresh_token(hass, aiohttp_client): """Test logging in with new user and refreshing tokens.""" client = await async_setup_auth(hass, aiohttp_client, setup_api=True) resp = await client.post('/auth/login_flow', json={ + 'client_id': CLIENT_ID, 'handler': ['insecure_example', None], 'redirect_uri': CLIENT_REDIRECT_URI, - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'test-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -24,9 +28,10 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): # Exchange code for tokens resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, 'grant_type': 'authorization_code', 'code': code - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 tokens = await resp.json() @@ -35,9 +40,10 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): # Use refresh token to get more tokens. resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, 'grant_type': 'refresh_token', 'refresh_token': tokens['refresh_token'] - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 tokens = await resp.json() diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py index 853c002ba46f33..28a924bb43a670 100644 --- a/tests/components/auth/test_init_link_user.py +++ b/tests/components/auth/test_init_link_user.py @@ -1,5 +1,7 @@ """Tests for the link user flow.""" -from . import async_setup_auth, CLIENT_AUTH, CLIENT_ID, CLIENT_REDIRECT_URI +from . import async_setup_auth + +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI async def async_get_code(hass, aiohttp_client): @@ -25,17 +27,19 @@ async def async_get_code(hass, aiohttp_client): client = await async_setup_auth(hass, aiohttp_client, config) resp = await client.post('/auth/login_flow', json={ + 'client_id': CLIENT_ID, 'handler': ['insecure_example', None], 'redirect_uri': CLIENT_REDIRECT_URI, - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'test-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -43,9 +47,10 @@ async def async_get_code(hass, aiohttp_client): # Exchange code for tokens resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, 'grant_type': 'authorization_code', 'code': code - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 tokens = await resp.json() @@ -57,17 +62,19 @@ async def async_get_code(hass, aiohttp_client): # Now authenticate with the 2nd flow resp = await client.post('/auth/login_flow', json={ + 'client_id': CLIENT_ID, 'handler': ['insecure_example', '2nd auth'], 'redirect_uri': CLIENT_REDIRECT_URI, - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': '2nd-user', 'password': '2nd-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() diff --git a/tests/components/auth/test_init_login_flow.py b/tests/components/auth/test_init_login_flow.py index ad39fba399737f..50bd03d6cedbcb 100644 --- a/tests/components/auth/test_init_login_flow.py +++ b/tests/components/auth/test_init_login_flow.py @@ -1,13 +1,13 @@ """Tests for the login flow.""" -from aiohttp.helpers import BasicAuth +from . import async_setup_auth -from . import async_setup_auth, CLIENT_AUTH, CLIENT_REDIRECT_URI +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI async def test_fetch_auth_providers(hass, aiohttp_client): """Test fetching auth providers.""" client = await async_setup_auth(hass, aiohttp_client) - resp = await client.get('/auth/providers', auth=CLIENT_AUTH) + resp = await client.get('/auth/providers') assert await resp.json() == [{ 'name': 'Example', 'type': 'insecure_example', @@ -15,14 +15,6 @@ async def test_fetch_auth_providers(hass, aiohttp_client): }] -async def test_fetch_auth_providers_require_valid_client(hass, aiohttp_client): - """Test fetching auth providers.""" - client = await async_setup_auth(hass, aiohttp_client) - resp = await client.get('/auth/providers', - auth=BasicAuth('invalid', 'bla')) - assert resp.status == 401 - - async def test_cannot_get_flows_in_progress(hass, aiohttp_client): """Test we cannot get flows in progress.""" client = await async_setup_auth(hass, aiohttp_client, []) @@ -34,18 +26,20 @@ async def test_invalid_username_password(hass, aiohttp_client): """Test we cannot get flows in progress.""" client = await async_setup_auth(hass, aiohttp_client) resp = await client.post('/auth/login_flow', json={ + 'client_id': CLIENT_ID, 'handler': ['insecure_example', None], 'redirect_uri': CLIENT_REDIRECT_URI - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() # Incorrect username resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'wrong-user', 'password': 'test-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -56,9 +50,10 @@ async def test_invalid_username_password(hass, aiohttp_client): # Incorrect password resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'wrong-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 00e3ee88d1660b..843866cbfbd6dc 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,7 +3,7 @@ from homeassistant.setup import async_setup_component -from tests.common import MockUser +from tests.common import MockUser, CLIENT_ID @pytest.fixture @@ -28,11 +28,6 @@ async def create_client(hass): def hass_access_token(hass): """Return an access token to access Home Assistant.""" user = MockUser().add_to_hass(hass) - client = hass.loop.run_until_complete(hass.auth.async_create_client( - 'Access Token Fixture', - redirect_uris=['/'], - no_secret=True, - )) refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(user, client)) + hass.auth.async_create_refresh_token(user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/test_auth.py b/tests/test_auth.py index 8096a081679232..3119c3d8d716a9 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,7 +6,8 @@ from homeassistant import auth, data_entry_flow from homeassistant.util import dt as dt_util -from tests.common import MockUser, ensure_auth_manager_loaded, flush_store +from tests.common import ( + MockUser, ensure_auth_manager_loaded, flush_store, CLIENT_ID) @pytest.fixture @@ -181,10 +182,7 @@ async def test_saving_loading(hass, hass_storage): }) user = await manager.async_get_or_create_user(step['result']) - client = await manager.async_create_client( - 'test', redirect_uris=['https://example.com']) - - refresh_token = await manager.async_create_refresh_token(user, client) + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) manager.async_create_access_token(refresh_token) @@ -195,10 +193,6 @@ async def test_saving_loading(hass, hass_storage): assert len(users) == 1 assert users[0] == user - clients = await store2.async_get_clients() - assert len(clients) == 1 - assert clients[0] == client - def test_access_token_expired(): """Test that the expired property on access tokens work.""" @@ -225,11 +219,10 @@ def test_access_token_expired(): async def test_cannot_retrieve_expired_access_token(hass): """Test that we cannot retrieve expired access tokens.""" manager = await auth.auth_manager_from_config(hass, []) - client = await manager.async_create_client('test') user = MockUser().add_to_auth_manager(manager) - refresh_token = await manager.async_create_refresh_token(user, client) + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) assert refresh_token.user.id is user.id - assert refresh_token.client_id is client.id + assert refresh_token.client_id == CLIENT_ID access_token = manager.async_create_access_token(refresh_token) assert manager.async_get_access_token(access_token.token) is access_token @@ -242,19 +235,6 @@ async def test_cannot_retrieve_expired_access_token(hass): assert manager.async_get_access_token(access_token.token) is None -async def test_get_or_create_client(hass): - """Test that get_or_create_client works.""" - manager = await auth.auth_manager_from_config(hass, []) - - client1 = await manager.async_get_or_create_client( - 'Test Client', redirect_uris=['https://test.com/1']) - assert client1.name is 'Test Client' - - client2 = await manager.async_get_or_create_client( - 'Test Client', redirect_uris=['https://test.com/1']) - assert client2.id is client1.id - - async def test_generating_system_user(hass): """Test that we can add a system user.""" manager = await auth.auth_manager_from_config(hass, []) @@ -274,10 +254,9 @@ async def test_refresh_token_requires_client_for_user(hass): with pytest.raises(ValueError): await manager.async_create_refresh_token(user) - client = await manager.async_get_or_create_client('Test client') - token = await manager.async_create_refresh_token(user, client) + token = await manager.async_create_refresh_token(user, CLIENT_ID) assert token is not None - assert token.client_id == client.id + assert token.client_id == CLIENT_ID async def test_refresh_token_not_requires_client_for_system_user(hass): @@ -285,10 +264,9 @@ async def test_refresh_token_not_requires_client_for_system_user(hass): manager = await auth.auth_manager_from_config(hass, []) user = await manager.async_create_system_user('Hass.io') assert user.system_generated is True - client = await manager.async_get_or_create_client('Test client') with pytest.raises(ValueError): - await manager.async_create_refresh_token(user, client) + await manager.async_create_refresh_token(user, CLIENT_ID) token = await manager.async_create_refresh_token(user) assert token is not None From 57977bcef3ddc005682d8d1b5311c76abd49c7f6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Jul 2018 18:26:51 +0200 Subject: [PATCH 079/147] Bump frontend to 20180709.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 4304742021f88a..3080177ba38c4b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180708.0'] +REQUIREMENTS = ['home-assistant-frontend==20180709.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 42d5c84c868bde..3fc5a23512871c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180708.0 +home-assistant-frontend==20180709.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88a54841b8ed5a..6f88c4b26ed4ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180708.0 +home-assistant-frontend==20180709.0 # homeassistant.components.homematicip_cloud homematicip==0.9.6 From 3b93fa80be21cc41007d1e5e291716e209855ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 9 Jul 2018 22:33:58 +0300 Subject: [PATCH 080/147] Add httplib2 to h.c.google requirements (#15385) --- homeassistant/components/calendar/google.py | 1 - homeassistant/components/google.py | 1 + requirements_all.txt | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index 87893125e6f74a..279fb1e2694ced 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.google_calendar/ """ -# pylint: disable=import-error import logging from datetime import timedelta diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index 203b1a94b7f9e8..fdbc3382072024 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -25,6 +25,7 @@ REQUIREMENTS = [ 'google-api-python-client==1.6.4', + 'httplib2==0.10.3', 'oauth2client==4.0.0', ] diff --git a/requirements_all.txt b/requirements_all.txt index 3fc5a23512871c..482c4cbbd49426 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -423,6 +423,7 @@ home-assistant-frontend==20180709.0 # homeassistant.components.homematicip_cloud homematicip==0.9.6 +# homeassistant.components.google # homeassistant.components.remember_the_milk httplib2==0.10.3 From 14a34f8c4b8c94a4db467a6b25141c04c62c7b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 9 Jul 2018 22:34:27 +0300 Subject: [PATCH 081/147] Remove some unneeded pylint import-error disables (#15386) --- homeassistant/components/device_tracker/mikrotik.py | 1 - homeassistant/components/eufy.py | 1 - homeassistant/components/light/eufy.py | 1 - homeassistant/components/switch/eufy.py | 1 - 4 files changed, 4 deletions(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index e9a7efeb64aab2..dfc66a412c39f8 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -66,7 +66,6 @@ def __init__(self, config): def connect_to_device(self): """Connect to Mikrotik method.""" - # pylint: disable=import-error import librouteros try: self.client = librouteros.connect( diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py index e86e7348d58fb7..69d4905228ad27 100644 --- a/homeassistant/components/eufy.py +++ b/homeassistant/components/eufy.py @@ -49,7 +49,6 @@ def setup(hass, config): """Set up Eufy devices.""" - # pylint: disable=import-error import lakeside if CONF_USERNAME in config[DOMAIN] and CONF_PASSWORD in config[DOMAIN]: diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py index 6f0a8816eea173..2e7370cb336f1b 100644 --- a/homeassistant/components/light/eufy.py +++ b/homeassistant/components/light/eufy.py @@ -36,7 +36,6 @@ class EufyLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error import lakeside self._temp = None diff --git a/homeassistant/components/switch/eufy.py b/homeassistant/components/switch/eufy.py index 891525d3979157..7320ea8d5571db 100644 --- a/homeassistant/components/switch/eufy.py +++ b/homeassistant/components/switch/eufy.py @@ -25,7 +25,6 @@ class EufySwitch(SwitchDevice): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error import lakeside self._state = None From 6ee8d9bd65e62a1251c83c71ff63b40387980a86 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Mon, 9 Jul 2018 21:35:06 +0200 Subject: [PATCH 082/147] Update ha-philipsjs to 0.0.5 (#15378) * Update requirements_all.txt * Update philips_js.py --- homeassistant/components/media_player/philips_js.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py index be0c0527f1bc9f..06f054a03f7232 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -20,7 +20,7 @@ from homeassistant.helpers.script import Script from homeassistant.util import Throttle -REQUIREMENTS = ['ha-philipsjs==0.0.4'] +REQUIREMENTS = ['ha-philipsjs==0.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 482c4cbbd49426..848364ed3f4a4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -391,7 +391,7 @@ gstreamer-player==1.1.0 ha-ffmpeg==1.9 # homeassistant.components.media_player.philips_js -ha-philipsjs==0.0.4 +ha-philipsjs==0.0.5 # homeassistant.components.sensor.geo_rss_events haversine==0.4.5 From e62bb299ff621876c7bac4b72033ae4de910c845 Mon Sep 17 00:00:00 2001 From: hanzoh Date: Mon, 9 Jul 2018 23:01:17 +0200 Subject: [PATCH 083/147] Add new voices to Amazon Polly (#15320) --- homeassistant/components/tts/amazon_polly.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index 46c1a24caa0e8a..d59331984b7122 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -38,7 +38,8 @@ 'Chantal', 'Celine', 'Mathieu', 'Dora', 'Karl', 'Carla', 'Giorgio', 'Mizuki', 'Liv', 'Lotte', 'Ruben', 'Ewa', 'Jacek', 'Jan', 'Maja', 'Ricardo', 'Vitoria', 'Cristiano', - 'Ines', 'Carmen', 'Maxim', 'Tatyana', 'Astrid', 'Filiz'] + 'Ines', 'Carmen', 'Maxim', 'Tatyana', 'Astrid', 'Filiz', + 'Aditi', 'Léa', 'Matthew', 'Seoyeon', 'Takumi', 'Vicki'] SUPPORTED_OUTPUT_FORMATS = ['mp3', 'ogg_vorbis', 'pcm'] From c5a2ffbcb9a0272f63d6e711ec102ce9819c22af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 9 Jul 2018 23:11:54 +0200 Subject: [PATCH 084/147] Add Cloudflare DNS component. (#15388) * Add Cloudflare DNS component * Removed man * Update .coveragerc * Update cloudflare.py * Update cloudflare.py * Changed records to be required * Fix typos, update order and other minor changes --- .coveragerc | 2 + homeassistant/components/cloudflare.py | 78 ++++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 83 insertions(+) create mode 100644 homeassistant/components/cloudflare.py diff --git a/.coveragerc b/.coveragerc index a100e2c0a4958d..8fbfb72d9309ba 100644 --- a/.coveragerc +++ b/.coveragerc @@ -64,6 +64,8 @@ omit = homeassistant/components/cast/* homeassistant/components/*/cast.py + homeassistant/components/cloudflare.py + homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py diff --git a/homeassistant/components/cloudflare.py b/homeassistant/components/cloudflare.py new file mode 100644 index 00000000000000..e55e15c68c1002 --- /dev/null +++ b/homeassistant/components/cloudflare.py @@ -0,0 +1,78 @@ +""" +Update the IP addresses of your Cloudflare DNS records. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/cloudflare/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['pycfdns==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RECORDS = 'records' + +DOMAIN = 'cloudflare' + +INTERVAL = timedelta(minutes=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ZONE): cv.string, + vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Cloudflare component.""" + from pycfdns import CloudflareUpdater + + cfupdate = CloudflareUpdater() + email = config[DOMAIN][CONF_EMAIL] + key = config[DOMAIN][CONF_API_KEY] + zone = config[DOMAIN][CONF_ZONE] + records = config[DOMAIN][CONF_RECORDS] + + def update_records_interval(now): + """Set up recurring update.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + def update_records_service(now): + """Set up service for manual trigger.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + track_time_interval(hass, update_records_interval, INTERVAL) + hass.services.register( + DOMAIN, 'update_records', update_records_service) + return True + + +def _update_cloudflare(cfupdate, email, key, zone, records): + """Update DNS records for a given zone.""" + _LOGGER.debug("Starting update for zone %s", zone) + + headers = cfupdate.set_header(email, key) + _LOGGER.debug("Header data defined as: %s", headers) + + zoneid = cfupdate.get_zoneID(headers, zone) + _LOGGER.debug("Zone ID is set to: %s", zoneid) + + update_records = cfupdate.get_recordInfo(headers, zoneid, zone, records) + _LOGGER.debug("Records: %s", update_records) + + result = cfupdate.update_records(headers, zoneid, update_records) + _LOGGER.debug("Update for zone %s is complete", zone) + + if result is not True: + _LOGGER.warning(result) + return True diff --git a/requirements_all.txt b/requirements_all.txt index 848364ed3f4a4a..0e221c436cd78f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -758,6 +758,9 @@ pyblackbird==0.5 # homeassistant.components.neato pybotvac==0.0.7 +# homeassistant.components.cloudflare +pycfdns==0.0.1 + # homeassistant.components.media_player.channels pychannels==1.0.0 From df8c59406b8d01a5993fb413db006d94c39b5100 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 10 Jul 2018 02:11:39 +0100 Subject: [PATCH 085/147] Add Facebox teach service (#14998) * Adds service * Address pylint * Update facebox.py * patch tests * Update facebox.py * Update test_facebox.py * Update facebox.py * Update facebox.py * Update facebox.py * Update test_facebox.py * Update test_facebox.py * Update facebox.py * Update facebox.py * Update facebox.py * Update facebox.py * Adds total_matched_faces * Update test_facebox.py * Update facebox.py * Update test_facebox.py * Update test_facebox.py * Remove fixtures Removes the fixtures which were causing `setup` to fail, replace with `@patch` * Fix teach service test and lint issues --- .../components/image_processing/facebox.py | 131 +++++++++++++++--- .../components/image_processing/services.yaml | 13 ++ .../image_processing/test_facebox.py | 121 +++++++++++++--- 3 files changed, 225 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/image_processing/facebox.py b/homeassistant/components/image_processing/facebox.py index f556b62e935425..c863f8045138e4 100644 --- a/homeassistant/components/image_processing/facebox.py +++ b/homeassistant/components/image_processing/facebox.py @@ -10,20 +10,26 @@ import requests import voluptuous as vol -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_NAME) from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingFaceEntity, ATTR_CONFIDENCE, CONF_SOURCE, - CONF_ENTITY_ID, CONF_NAME) + CONF_ENTITY_ID, CONF_NAME, DOMAIN) from homeassistant.const import (CONF_IP_ADDRESS, CONF_PORT) _LOGGER = logging.getLogger(__name__) ATTR_BOUNDING_BOX = 'bounding_box' +ATTR_CLASSIFIER = 'classifier' ATTR_IMAGE_ID = 'image_id' ATTR_MATCHED = 'matched' CLASSIFIER = 'facebox' +DATA_FACEBOX = 'facebox_classifiers' +EVENT_CLASSIFIER_TEACH = 'image_processing.teach_classifier' +FILE_PATH = 'file_path' +SERVICE_TEACH_FACE = 'facebox_teach_face' TIMEOUT = 9 @@ -32,6 +38,12 @@ vol.Required(CONF_PORT): cv.port, }) +SERVICE_TEACH_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string, + vol.Required(FILE_PATH): cv.string, +}) + def encode_image(image): """base64 encode an image stream.""" @@ -63,18 +75,65 @@ def parse_faces(api_faces): return known_faces +def post_image(url, image): + """Post an image to the classifier.""" + try: + response = requests.post( + url, + json={"base64": encode_image(image)}, + timeout=TIMEOUT + ) + return response + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + + +def valid_file_path(file_path): + """Check that a file_path points to a valid file.""" + try: + cv.isfile(file_path) + return True + except vol.Invalid: + _LOGGER.error( + "%s error: Invalid file path: %s", CLASSIFIER, file_path) + return False + + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the classifier.""" + if DATA_FACEBOX not in hass.data: + hass.data[DATA_FACEBOX] = [] + entities = [] for camera in config[CONF_SOURCE]: - entities.append(FaceClassifyEntity( + facebox = FaceClassifyEntity( config[CONF_IP_ADDRESS], config[CONF_PORT], camera[CONF_ENTITY_ID], - camera.get(CONF_NAME) - )) + camera.get(CONF_NAME)) + entities.append(facebox) + hass.data[DATA_FACEBOX].append(facebox) add_devices(entities) + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get('entity_id') + + classifiers = hass.data[DATA_FACEBOX] + if entity_ids: + classifiers = [c for c in classifiers if c.entity_id in entity_ids] + + for classifier in classifiers: + name = service.data.get(ATTR_NAME) + file_path = service.data.get(FILE_PATH) + classifier.teach(name, file_path) + + hass.services.register( + DOMAIN, + SERVICE_TEACH_FACE, + service_handle, + schema=SERVICE_TEACH_SCHEMA) + class FaceClassifyEntity(ImageProcessingFaceEntity): """Perform a face classification.""" @@ -82,7 +141,8 @@ class FaceClassifyEntity(ImageProcessingFaceEntity): def __init__(self, ip, port, camera_entity, name=None): """Init with the API key and model id.""" super().__init__() - self._url = "http://{}:{}/{}/check".format(ip, port, CLASSIFIER) + self._url_check = "http://{}:{}/{}/check".format(ip, port, CLASSIFIER) + self._url_teach = "http://{}:{}/{}/teach".format(ip, port, CLASSIFIER) self._camera = camera_entity if name: self._name = name @@ -94,28 +154,54 @@ def __init__(self, ip, port, camera_entity, name=None): def process_image(self, image): """Process an image.""" - response = {} - try: - response = requests.post( - self._url, - json={"base64": encode_image(image)}, - timeout=TIMEOUT - ).json() - except requests.exceptions.ConnectionError: - _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) - response['success'] = False - - if response['success']: - total_faces = response['facesCount'] - faces = parse_faces(response['faces']) - self._matched = get_matched_faces(faces) - self.process_faces(faces, total_faces) + response = post_image(self._url_check, image) + if response is not None: + response_json = response.json() + if response_json['success']: + total_faces = response_json['facesCount'] + faces = parse_faces(response_json['faces']) + self._matched = get_matched_faces(faces) + self.process_faces(faces, total_faces) else: self.total_faces = None self.faces = [] self._matched = {} + def teach(self, name, file_path): + """Teach classifier a face name.""" + if (not self.hass.config.is_allowed_path(file_path) + or not valid_file_path(file_path)): + return + with open(file_path, 'rb') as open_file: + response = requests.post( + self._url_teach, + data={ATTR_NAME: name, 'id': file_path}, + files={'file': open_file}) + + if response.status_code == 200: + self.hass.bus.fire( + EVENT_CLASSIFIER_TEACH, { + ATTR_CLASSIFIER: CLASSIFIER, + ATTR_NAME: name, + FILE_PATH: file_path, + 'success': True, + 'message': None + }) + + elif response.status_code == 400: + _LOGGER.warning( + "%s teaching of file %s failed with message:%s", + CLASSIFIER, file_path, response.text) + self.hass.bus.fire( + EVENT_CLASSIFIER_TEACH, { + ATTR_CLASSIFIER: CLASSIFIER, + ATTR_NAME: name, + FILE_PATH: file_path, + 'success': False, + 'message': response.text + }) + @property def camera_entity(self): """Return camera entity id from process pictures.""" @@ -131,4 +217,5 @@ def device_state_attributes(self): """Return the classifier attributes.""" return { 'matched_faces': self._matched, + 'total_matched_faces': len(self._matched), } diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index 1f1fa347dc9b5f..0689c34c1a3eec 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -6,3 +6,16 @@ scan: entity_id: description: Name(s) of entities to scan immediately. example: 'image_processing.alpr_garage' + +facebox_teach_face: + description: Teach facebox a face using a file. + fields: + entity_id: + description: The facebox entity to teach. + example: 'image_processing.facebox' + name: + description: The name of the face to teach. + example: 'my_name' + file_path: + description: The path to the image file. + example: '/images/my_image.jpg' diff --git a/tests/components/image_processing/test_facebox.py b/tests/components/image_processing/test_facebox.py index 9449ebf5f71de7..86811f94db3fd8 100644 --- a/tests/components/image_processing/test_facebox.py +++ b/tests/components/image_processing/test_facebox.py @@ -1,5 +1,5 @@ """The tests for the facebox component.""" -from unittest.mock import patch +from unittest.mock import Mock, mock_open, patch import pytest import requests @@ -13,21 +13,26 @@ import homeassistant.components.image_processing as ip import homeassistant.components.image_processing.facebox as fb +# pylint: disable=redefined-outer-name + MOCK_IP = '192.168.0.1' MOCK_PORT = '8080' # Mock data returned by the facebox API. +MOCK_ERROR = "No face found" MOCK_FACE = {'confidence': 0.5812028911604818, 'id': 'john.jpg', 'matched': True, 'name': 'John Lennon', - 'rect': {'height': 75, 'left': 63, 'top': 262, 'width': 74} - } + 'rect': {'height': 75, 'left': 63, 'top': 262, 'width': 74}} + +MOCK_FILE_PATH = '/images/mock.jpg' MOCK_JSON = {"facesCount": 1, "success": True, - "faces": [MOCK_FACE] - } + "faces": [MOCK_FACE]} + +MOCK_NAME = 'mock_name' # Faces data after parsing. PARSED_FACES = [{ATTR_NAME: 'John Lennon', @@ -38,8 +43,7 @@ 'height': 75, 'left': 63, 'top': 262, - 'width': 74}, - }] + 'width': 74}}] MATCHED_FACES = {'John Lennon': 58.12} @@ -58,16 +62,42 @@ } +@pytest.fixture +def mock_isfile(): + """Mock os.path.isfile.""" + with patch('homeassistant.components.image_processing.facebox.cv.isfile', + return_value=True) as _mock_isfile: + yield _mock_isfile + + +@pytest.fixture +def mock_open_file(): + """Mock open.""" + mopen = mock_open() + with patch('homeassistant.components.image_processing.facebox.open', + mopen, create=True) as _mock_open: + yield _mock_open + + def test_encode_image(): """Test that binary data is encoded correctly.""" assert fb.encode_image(b'test') == 'dGVzdA==' +def test_get_matched_faces(): + """Test that matched_faces are parsed correctly.""" + assert fb.get_matched_faces(PARSED_FACES) == MATCHED_FACES + + def test_parse_faces(): """Test parsing of raw face data, and generation of matched_faces.""" - parsed_faces = fb.parse_faces(MOCK_JSON['faces']) - assert parsed_faces == PARSED_FACES - assert fb.get_matched_faces(parsed_faces) == MATCHED_FACES + assert fb.parse_faces(MOCK_JSON['faces']) == PARSED_FACES + + +@patch('os.access', Mock(return_value=False)) +def test_valid_file_path(): + """Test that an invalid file_path is caught.""" + assert not fb.valid_file_path('test_path') @pytest.fixture @@ -110,6 +140,7 @@ def mock_face_event(event): state = hass.states.get(VALID_ENTITY_ID) assert state.state == '1' assert state.attributes.get('matched_faces') == MATCHED_FACES + assert state.attributes.get('total_matched_faces') == 1 PARSED_FACES[0][ATTR_ENTITY_ID] = VALID_ENTITY_ID # Update. assert state.attributes.get('faces') == PARSED_FACES @@ -134,7 +165,7 @@ async def test_connection_error(hass, mock_image): with requests_mock.Mocker() as mock_req: url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) mock_req.register_uri( - 'POST', url, exc=requests.exceptions.ConnectTimeout) + 'POST', url, exc=requests.exceptions.ConnectTimeout) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, @@ -147,15 +178,69 @@ async def test_connection_error(hass, mock_image): assert state.attributes.get('matched_faces') == {} +async def test_teach_service(hass, mock_image, mock_isfile, mock_open_file): + """Test teaching of facebox.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + teach_events = [] + + @callback + def mock_teach_event(event): + """Mock event.""" + teach_events.append(event) + + hass.bus.async_listen( + 'image_processing.teach_classifier', mock_teach_event) + + # Patch out 'is_allowed_path' as the mock files aren't allowed + hass.config.is_allowed_path = Mock(return_value=True) + + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, status_code=200) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, + ATTR_NAME: MOCK_NAME, + fb.FILE_PATH: MOCK_FILE_PATH} + await hass.services.async_call( + ip.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data) + await hass.async_block_till_done() + + assert len(teach_events) == 1 + assert teach_events[0].data[fb.ATTR_CLASSIFIER] == fb.CLASSIFIER + assert teach_events[0].data[ATTR_NAME] == MOCK_NAME + assert teach_events[0].data[fb.FILE_PATH] == MOCK_FILE_PATH + assert teach_events[0].data['success'] + assert not teach_events[0].data['message'] + + # Now test the failed teaching. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, status_code=400, text=MOCK_ERROR) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, + ATTR_NAME: MOCK_NAME, + fb.FILE_PATH: MOCK_FILE_PATH} + await hass.services.async_call(ip.DOMAIN, + fb.SERVICE_TEACH_FACE, + service_data=data) + await hass.async_block_till_done() + + assert len(teach_events) == 2 + assert teach_events[1].data[fb.ATTR_CLASSIFIER] == fb.CLASSIFIER + assert teach_events[1].data[ATTR_NAME] == MOCK_NAME + assert teach_events[1].data[fb.FILE_PATH] == MOCK_FILE_PATH + assert not teach_events[1].data['success'] + assert teach_events[1].data['message'] == MOCK_ERROR + + async def test_setup_platform_with_name(hass): """Setup platform with one entity and a name.""" - MOCK_NAME = 'mock_name' - NAMED_ENTITY_ID = 'image_processing.{}'.format(MOCK_NAME) + named_entity_id = 'image_processing.{}'.format(MOCK_NAME) - VALID_CONFIG_NAMED = VALID_CONFIG.copy() - VALID_CONFIG_NAMED[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME + valid_config_named = VALID_CONFIG.copy() + valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG_NAMED) - assert hass.states.get(NAMED_ENTITY_ID) - state = hass.states.get(NAMED_ENTITY_ID) + await async_setup_component(hass, ip.DOMAIN, valid_config_named) + assert hass.states.get(named_entity_id) + state = hass.states.get(named_entity_id) assert state.attributes.get(CONF_FRIENDLY_NAME) == MOCK_NAME From dbdd0a1f562ba88bd7c998da32b46464d579321c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 Jul 2018 11:20:22 +0200 Subject: [PATCH 086/147] Expire auth code after 10 minutes (#15381) --- homeassistant/components/auth/__init__.py | 20 ++++++++++++++-- tests/components/auth/test_init.py | 28 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index c41b417576e990..3e236876d6a7ae 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -102,6 +102,7 @@ "token_type": "Bearer" } """ +from datetime import timedelta import logging import uuid @@ -114,6 +115,7 @@ FlowManagerIndexView, FlowManagerResourceView) from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.util import dt as dt_util from . import indieauth @@ -349,12 +351,26 @@ def _create_cred_store(): def store_credentials(client_id, credentials): """Store credentials and return a code to retrieve it.""" code = uuid.uuid4().hex - temp_credentials[(client_id, code)] = credentials + temp_credentials[(client_id, code)] = (dt_util.utcnow(), credentials) return code @callback def retrieve_credentials(client_id, code): """Retrieve credentials.""" - return temp_credentials.pop((client_id, code), None) + key = (client_id, code) + + if key not in temp_credentials: + return None + + created, credentials = temp_credentials.pop(key) + + # OAuth 4.2.1 + # The authorization code MUST expire shortly after it is issued to + # mitigate the risk of leaks. A maximum authorization code lifetime of + # 10 minutes is RECOMMENDED. + if dt_util.utcnow() - created < timedelta(minutes=10): + return credentials + + return None return store_credentials, retrieve_credentials diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 68a77d18d56d9e..c5c46d55e39971 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -1,4 +1,10 @@ """Integration tests for the auth component.""" +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.util.dt import utcnow +from homeassistant.components import auth + from . import async_setup_auth from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI @@ -58,3 +64,25 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): 'authorization': 'Bearer {}'.format(tokens['access_token']) }) assert resp.status == 200 + + +def test_credential_store_expiration(): + """Test that the credential store will not return expired tokens.""" + store, retrieve = auth._create_cred_store() + client_id = 'bla' + credentials = 'creds' + now = utcnow() + + with patch('homeassistant.util.dt.utcnow', return_value=now): + code = store(client_id, credentials) + + with patch('homeassistant.util.dt.utcnow', + return_value=now + timedelta(minutes=10)): + assert retrieve(client_id, code) is None + + with patch('homeassistant.util.dt.utcnow', return_value=now): + code = store(client_id, credentials) + + with patch('homeassistant.util.dt.utcnow', + return_value=now + timedelta(minutes=9, seconds=59)): + assert retrieve(client_id, code) == credentials From 2ee62b10bc3c51e642cb8ec2fe5e044b23c0fa46 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 Jul 2018 12:01:52 +0200 Subject: [PATCH 087/147] Bump frontend to 20180710.0 --- homeassistant/components/frontend/__init__.py | 6 ++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3080177ba38c4b..0fa9f90805d0d9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180709.0'] +REQUIREMENTS = ['home-assistant-frontend==20180710.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -382,6 +382,8 @@ async def get(self, request, extra=None): # do not try to auto connect on load no_auth = '0' + use_oauth = '1' if self.auth_active else '0' + template = await hass.async_add_job(self.get_template, latest) extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 @@ -390,7 +392,7 @@ async def get(self, request, extra=None): no_auth=no_auth, theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[extra_key], - client_id=self.auth_active + use_oauth=use_oauth ) return web.Response(text=template.render(**template_params), diff --git a/requirements_all.txt b/requirements_all.txt index 0e221c436cd78f..3f0d3f8314ab29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180709.0 +home-assistant-frontend==20180710.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f88c4b26ed4ba..1daaa106e99549 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180709.0 +home-assistant-frontend==20180710.0 # homeassistant.components.homematicip_cloud homematicip==0.9.6 From 9ea0c409e6cea69cce632079548165ad5a9f2554 Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Tue, 10 Jul 2018 12:30:48 +0200 Subject: [PATCH 088/147] Improve NetAtmo sensors update logic (#14866) * Added a "last update" sensor that could be used by automations + cosmetic changes * Improved the update logic of sensor data The platform is now continuously adjusting the refresh interval in order to synchronize with the expected next update from the NetAtmo cloud. This significantly improves reaction time of automations while keeping the refresh time to the recommended value (10 minutes). * Linting * Incorporated the advanced Throttle class to support adaptive throttling, as opposed to integrating it in the core framework. Following code review, it was suggested to implement the specialised Throttle class in this platform instead of making a change in the general util package. Except that the required change (about 4 LoC) is part of the only relevant piece of code of that class, therefore this commit includes a full copy of the Throttle class from homeassistant.util, plus the extra feature to support adaptive throttling. * Cosmetic changes on the introduced "last updated" sensor * Alternate implementation for the adaptive throttling Ensure the updates from the cloud are throttled and adapted to the last update time provided by NetAtmo, without using the Throttle decorator. Similar logic and similar usage of a lock to protect the execution of the remote update. * Linting --- homeassistant/components/sensor/netatmo.py | 72 +++++++++++++++++----- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 191e587feafd1d..bdc2c5990d9256 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -5,7 +5,8 @@ https://home-assistant.io/components/sensor.netatmo/ """ import logging -from datetime import timedelta +from time import time +import threading import voluptuous as vol @@ -14,7 +15,6 @@ TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -24,8 +24,8 @@ DEPENDENCIES = ['netatmo'] -# NetAtmo Data is uploaded to server every 10 minutes -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=600) +# This is the NetAtmo data upload interval in seconds +NETATMO_UPDATE_INTERVAL = 600 SENSOR_TYPES = { 'temperature': ['Temperature', TEMP_CELSIUS, None, @@ -50,7 +50,8 @@ 'rf_status': ['Radio', '', 'mdi:signal', None], 'rf_status_lvl': ['Radio_lvl', '', 'mdi:signal', None], 'wifi_status': ['Wifi', '', 'mdi:wifi', None], - 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi', None] + 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi', None], + 'lastupdated': ['Last Updated', 's', 'mdi:timer', None], } MODULE_SCHEMA = vol.Schema({ @@ -76,11 +77,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # Iterate each module for module_name, monitored_conditions in\ config[CONF_MODULES].items(): - # Test if module exist """ + # Test if module exists if module_name not in data.get_module_names(): _LOGGER.error('Module name: "%s" not found', module_name) continue - # Only create sensor for monitored """ + # Only create sensors for monitored properties for variable in monitored_conditions: dev.append(NetAtmoSensor(data, module_name, variable)) else: @@ -285,6 +286,8 @@ def update(self): self._state = "High" elif data['wifi_status'] <= 55: self._state = "Full" + elif self.type == 'lastupdated': + self._state = int(time() - data['When']) class NetAtmoData(object): @@ -296,20 +299,57 @@ def __init__(self, auth, station): self.data = None self.station_data = None self.station = station + self._next_update = time() + self._update_in_progress = threading.Lock() def get_module_names(self): """Return all module available on the API as a list.""" self.update() return self.data.keys() - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Call the Netatmo API to update the data.""" - import pyatmo - self.station_data = pyatmo.WeatherStationData(self.auth) + """Call the Netatmo API to update the data. - if self.station is not None: - self.data = self.station_data.lastData( - station=self.station, exclude=3600) - else: - self.data = self.station_data.lastData(exclude=3600) + This method is not throttled by the builtin Throttle decorator + but with a custom logic, which takes into account the time + of the last update from the cloud. + """ + if time() < self._next_update or \ + not self._update_in_progress.acquire(False): + return + + try: + import pyatmo + self.station_data = pyatmo.WeatherStationData(self.auth) + + if self.station is not None: + self.data = self.station_data.lastData( + station=self.station, exclude=3600) + else: + self.data = self.station_data.lastData(exclude=3600) + + newinterval = 0 + for module in self.data: + if 'When' in self.data[module]: + newinterval = self.data[module]['When'] + break + if newinterval: + # Try and estimate when fresh data will be available + newinterval += NETATMO_UPDATE_INTERVAL - time() + if newinterval > NETATMO_UPDATE_INTERVAL - 30: + newinterval = NETATMO_UPDATE_INTERVAL + else: + if newinterval < NETATMO_UPDATE_INTERVAL / 2: + # Never hammer the NetAtmo API more than + # twice per update interval + newinterval = NETATMO_UPDATE_INTERVAL / 2 + _LOGGER.warning( + "NetAtmo refresh interval reset to %d seconds", + newinterval) + else: + # Last update time not found, fall back to default value + newinterval = NETATMO_UPDATE_INTERVAL + + self._next_update = time() + newinterval + finally: + self._update_in_progress.release() From b65d7daed83c49d796669e1eddf1b63424fdc44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 10 Jul 2018 13:19:32 +0200 Subject: [PATCH 089/147] removed unused return (#15402) --- homeassistant/components/cloudflare.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/cloudflare.py b/homeassistant/components/cloudflare.py index e55e15c68c1002..ae400ca638569d 100644 --- a/homeassistant/components/cloudflare.py +++ b/homeassistant/components/cloudflare.py @@ -75,4 +75,3 @@ def _update_cloudflare(cfupdate, email, key, zone, records): if result is not True: _LOGGER.warning(result) - return True From f32098abe4fbacb947e31cefd2afa7da7911a454 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 10 Jul 2018 13:26:42 +0200 Subject: [PATCH 090/147] Fix confused brightness of xiaomi_aqara gateway light (#15314) --- homeassistant/components/light/xiaomi_aqara.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/xiaomi_aqara.py b/homeassistant/components/light/xiaomi_aqara.py index 37ae60e3494dbb..75c85a4bfcfb7d 100644 --- a/homeassistant/components/light/xiaomi_aqara.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -31,7 +31,7 @@ def __init__(self, device, name, xiaomi_hub): """Initialize the XiaomiGatewayLight.""" self._data_key = 'rgb' self._hs = (0, 0) - self._brightness = 180 + self._brightness = 100 XiaomiDevice.__init__(self, device, name, xiaomi_hub) @@ -64,7 +64,7 @@ def parse_data(self, data, raw_data): brightness = rgba[0] rgb = rgba[1:] - self._brightness = int(255 * brightness / 100) + self._brightness = brightness self._hs = color_util.color_RGB_to_hs(*rgb) self._state = True return True @@ -72,7 +72,7 @@ def parse_data(self, data, raw_data): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness + return int(255 * self._brightness / 100) @property def hs_color(self): From fd568d77c7adc0c3dba02c4bbe60b3be8683a495 Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Tue, 10 Jul 2018 15:51:37 +0200 Subject: [PATCH 091/147] Fix liveboxplaytv empty channel list (#15404) --- homeassistant/components/media_player/liveboxplaytv.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 4fe4da5a94270e..6b161f86ab0552 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -88,6 +88,8 @@ def async_update(self): import pyteleloisirs try: self._state = self.refresh_state() + # Update channel list + self.refresh_channel_list() # Update current channel channel = self._client.channel if channel is not None: From 1f6331c69d5873fa308611236bc92a1ed8d2835e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 Jul 2018 20:33:03 +0200 Subject: [PATCH 092/147] Fix credentials lookup (#15409) --- homeassistant/auth.py | 5 +---- tests/test_auth.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/auth.py b/homeassistant/auth.py index ae191f24c61ffd..c84f5e83ef0aa5 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -311,10 +311,7 @@ async def async_get_or_create_user(self, credentials): if not credentials.is_new: for user in await self._store.async_get_users(): for creds in user.credentials: - if (creds.auth_provider_type == - credentials.auth_provider_type - and creds.auth_provider_id == - credentials.auth_provider_id): + if creds.id == credentials.id: return user raise ValueError('Unable to find the user.') diff --git a/tests/test_auth.py b/tests/test_auth.py index 3119c3d8d716a9..a53c5aaec99b8c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -94,6 +94,21 @@ async def test_login_as_existing_user(mock_hass): }]) ensure_auth_manager_loaded(manager) + # Add a fake user that we're not going to log in with + user = MockUser( + id='mock-user2', + is_owner=False, + is_active=False, + name='Not user', + ).add_to_auth_manager(manager) + user.credentials.append(auth.Credentials( + id='mock-id2', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'other-user'}, + is_new=False, + )) + # Add fake user with credentials for example auth provider. user = MockUser( id='mock-user', From 6197fe0121fad3890129518c688bd9539284005c Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Wed, 11 Jul 2018 03:27:22 -0400 Subject: [PATCH 093/147] Change Ring binary_sensor frequency polling to avoid rate limit exceeded errors (#15414) --- homeassistant/components/binary_sensor/ring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index e84009301ab752..4f2ea408e7f46a 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = timedelta(seconds=10) # Sensor types: Name, category, device_class SENSOR_TYPES = { From 43b31e88ba8de95e02ffd4dd35202350ac26398a Mon Sep 17 00:00:00 2001 From: huangyupeng Date: Thu, 12 Jul 2018 16:19:35 +0800 Subject: [PATCH 094/147] Add Tuya component and switch support (#15399) * support for tuya platform * support tuya platform * lint fix * change dependency * add tuya platform support * remove tuya platform except switch. fix code as required * fix the code as review required * fix as required * fix a mistake --- .coveragerc | 3 + homeassistant/components/switch/tuya.py | 47 +++++++ homeassistant/components/tuya.py | 160 ++++++++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 213 insertions(+) create mode 100644 homeassistant/components/switch/tuya.py create mode 100644 homeassistant/components/tuya.py diff --git a/.coveragerc b/.coveragerc index 8fbfb72d9309ba..73a79c2d87bf7a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -343,6 +343,9 @@ omit = homeassistant/components/zoneminder.py homeassistant/components/*/zoneminder.py + homeassistant/components/tuya.py + homeassistant/components/*/tuya.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py diff --git a/homeassistant/components/switch/tuya.py b/homeassistant/components/switch/tuya.py new file mode 100644 index 00000000000000..4f69e76f954b25 --- /dev/null +++ b/homeassistant/components/switch/tuya.py @@ -0,0 +1,47 @@ +""" +Support for Tuya switch. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.tuya/ +""" +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice + +DEPENDENCIES = ['tuya'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya Switch device.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaSwitch(device)) + add_devices(devices) + + +class TuyaSwitch(TuyaDevice, SwitchDevice): + """Tuya Switch Device.""" + + def __init__(self, tuya): + """Init Tuya switch device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + + @property + def is_on(self): + """Return true if switch is on.""" + return self.tuya.state() + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.tuya.turn_on() + + def turn_off(self, **kwargs): + """Turn the device off.""" + self.tuya.turn_off() diff --git a/homeassistant/components/tuya.py b/homeassistant/components/tuya.py new file mode 100644 index 00000000000000..7263871e249a0a --- /dev/null +++ b/homeassistant/components/tuya.py @@ -0,0 +1,160 @@ +""" +Support for Tuya Smart devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tuya/ +""" +from datetime import timedelta +import logging +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import ( + dispatcher_send, async_dispatcher_connect) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['tuyapy==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_COUNTRYCODE = 'country_code' + +DOMAIN = 'tuya' +DATA_TUYA = 'data_tuya' + +SIGNAL_DELETE_ENTITY = 'tuya_delete' +SIGNAL_UPDATE_ENTITY = 'tuya_update' + +SERVICE_FORCE_UPDATE = 'force_update' +SERVICE_PULL_DEVICES = 'pull_devices' + +TUYA_TYPE_TO_HA = { + 'switch': 'switch' +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_COUNTRYCODE): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up Tuya Component.""" + from tuyapy import TuyaApi + + tuya = TuyaApi() + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + country_code = config[DOMAIN][CONF_COUNTRYCODE] + + hass.data[DATA_TUYA] = tuya + tuya.init(username, password, country_code) + hass.data[DOMAIN] = { + 'entities': {} + } + + def load_devices(device_list): + """Load new devices by device_list.""" + device_type_list = {} + for device in device_list: + dev_type = device.device_type() + if (dev_type in TUYA_TYPE_TO_HA and + device.object_id() not in hass.data[DOMAIN]['entities']): + ha_type = TUYA_TYPE_TO_HA[dev_type] + if ha_type not in device_type_list: + device_type_list[ha_type] = [] + device_type_list[ha_type].append(device.object_id()) + hass.data[DOMAIN]['entities'][device.object_id()] = None + for ha_type, dev_ids in device_type_list.items(): + discovery.load_platform( + hass, ha_type, DOMAIN, {'dev_ids': dev_ids}, config) + + device_list = tuya.get_all_devices() + load_devices(device_list) + + def poll_devices_update(event_time): + """Check if accesstoken is expired and pull device list from server.""" + _LOGGER.debug("Pull devices from Tuya.") + tuya.poll_devices_update() + # Add new discover device. + device_list = tuya.get_all_devices() + load_devices(device_list) + # Delete not exist device. + newlist_ids = [] + for device in device_list: + newlist_ids.append(device.object_id()) + for dev_id in list(hass.data[DOMAIN]['entities']): + if dev_id not in newlist_ids: + dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id) + hass.data[DOMAIN]['entities'].pop(dev_id) + + track_time_interval(hass, poll_devices_update, timedelta(minutes=5)) + + hass.services.register(DOMAIN, SERVICE_PULL_DEVICES, poll_devices_update) + + def force_update(call): + """Force all devices to pull data.""" + dispatcher_send(hass, SIGNAL_UPDATE_ENTITY) + + hass.services.register(DOMAIN, SERVICE_FORCE_UPDATE, force_update) + + return True + + +class TuyaDevice(Entity): + """Tuya base device.""" + + def __init__(self, tuya): + """Init Tuya devices.""" + self.tuya = tuya + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + dev_id = self.tuya.object_id() + self.hass.data[DOMAIN]['entities'][dev_id] = self.entity_id + async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback) + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback) + + @property + def object_id(self): + """Return Tuya device id.""" + return self.tuya.object_id() + + @property + def name(self): + """Return Tuya device name.""" + return self.tuya.name() + + @property + def icon(self): + """Return the entity picture to use in the frontend, if any.""" + return self.tuya.iconurl() + + @property + def available(self): + """Return if the device is available.""" + return self.tuya.available() + + def update(self): + """Refresh Tuya device data.""" + self.tuya.update() + + @callback + def _delete_callback(self, dev_id): + """Remove this entity.""" + if dev_id == self.object_id: + self.hass.async_add_job(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) diff --git a/requirements_all.txt b/requirements_all.txt index 3f0d3f8314ab29..3b216db656db12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1344,6 +1344,9 @@ total_connect_client==0.18 # homeassistant.components.switch.transmission transmissionrpc==0.11 +# homeassistant.components.tuya +tuyapy==0.1.1 + # homeassistant.components.twilio twilio==5.7.0 From 4c6394b3079412ca8b2f7e39bd67cdaa11cff8fe Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Thu, 12 Jul 2018 11:49:39 +0200 Subject: [PATCH 095/147] Fix HomeMatic variables (#15417) * Update __init__.py * Update requirements_all.txt --- homeassistant/components/homematic/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 1428bbd3e563f7..12de686d232d3e 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.44'] +REQUIREMENTS = ['pyhomematic==0.1.45'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3b216db656db12..0690539bdee039 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -847,7 +847,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.44 +pyhomematic==0.1.45 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 From c5875365471a9d6eedaa82971c685d3cfb9610e0 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Thu, 12 Jul 2018 02:52:37 -0700 Subject: [PATCH 096/147] Ignore some HomeKit devices (#15316) There are some devices that speak HomeKit that we shouldn't expose. Some bridges (such as the Hue) provide reduced functionality over HomeKit and have a functional native API, so should be ignored. We also shouldn't offer to configure the built-in Home Assistant HomeKit bridge. --- homeassistant/components/homekit_controller/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 34fdcb2c035e86..237a6d219f0c3e 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -26,6 +26,12 @@ 'thermostat': 'climate', } +HOMEKIT_IGNORE = [ + 'BSB002', + 'Home Assistant Bridge', + 'TRADFRI gateway' +] + KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) KNOWN_DEVICES = "{}-devices".format(DOMAIN) @@ -237,6 +243,9 @@ def discovery_dispatch(service, discovery_info): hkid = discovery_info['properties']['id'] config_num = int(discovery_info['properties']['c#']) + if model in HOMEKIT_IGNORE: + return + # Only register a device once, but rescan if the config has changed if hkid in hass.data[KNOWN_DEVICES]: device = hass.data[KNOWN_DEVICES][hkid] From b557c17f764ad46480aa3851a303a40f345d2214 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 12 Jul 2018 17:17:00 +0200 Subject: [PATCH 097/147] Make LimitlessLED color/temperature attributes mutually exclusive (#15298) --- homeassistant/components/light/limitlessled.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 71d3f9d95d7177..19aff97491e9b0 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -46,7 +46,7 @@ WHITE = [0, 0] SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | - SUPPORT_TRANSITION) + SUPPORT_EFFECT | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_DIMMER = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_FLASH | SUPPORT_COLOR | @@ -239,6 +239,8 @@ def max_mireds(self): @property def color_temp(self): """Return the temperature property.""" + if self.hs_color is not None: + return None return self._temperature @property @@ -247,6 +249,9 @@ def hs_color(self): if self._effect == EFFECT_NIGHT: return None + if self._color is None or self._color[1] == 0: + return None + return self._color @property From 4a6afc561454037e2c880d1fc3e977d97c8d12bb Mon Sep 17 00:00:00 2001 From: Mattias Welponer Date: Fri, 13 Jul 2018 03:57:41 +0200 Subject: [PATCH 098/147] Add HomematicIP alarm control panel (#15342) * Add HomematicIP security zone * Update access point tests * Fix state if not armed and coments * Add comment for the empty state_attributes * Fix comment * Fix spelling --- .../alarm_control_panel/__init__.py | 13 ++- .../alarm_control_panel/homematicip_cloud.py | 88 +++++++++++++++++++ .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/test_hap.py | 8 +- 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/alarm_control_panel/homematicip_cloud.py diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f81d2ef1037cd6..84a72945a7e267 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -121,7 +121,7 @@ def alarm_arm_custom_bypass(hass, code=None, entity_id=None): @asyncio.coroutine def async_setup(hass, config): """Track states and offer events for sensors.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) yield from component.async_setup(config) @@ -154,6 +154,17 @@ def async_alarm_service_handler(service): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +# pylint: disable=no-self-use class AlarmControlPanel(Entity): """An abstract class for alarm control devices.""" diff --git a/homeassistant/components/alarm_control_panel/homematicip_cloud.py b/homeassistant/components/alarm_control_panel/homematicip_cloud.py new file mode 100644 index 00000000000000..893fa76c44b4b9 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/homematicip_cloud.py @@ -0,0 +1,88 @@ +""" +Support for HomematicIP alarm control panel. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.homematicip_cloud/ +""" + +import logging + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED) +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) + + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + +HMIP_OPEN = 'OPEN' +HMIP_ZONE_AWAY = 'EXTERNAL' +HMIP_ZONE_HOME = 'INTERNAL' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the HomematicIP alarm control devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP alarm control panel from a config entry.""" + from homematicip.aio.group import AsyncSecurityZoneGroup + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for group in home.groups: + if isinstance(group, AsyncSecurityZoneGroup): + devices.append(HomematicipSecurityZone(home, group)) + + if devices: + async_add_devices(devices) + + +class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): + """Representation of an HomematicIP security zone group.""" + + def __init__(self, home, device): + """Initialize the security zone group.""" + device.modelType = 'Group-SecurityZone' + device.windowState = '' + super().__init__(home, device) + + @property + def state(self): + """Return the state of the device.""" + if self._device.active: + if (self._device.sabotage or self._device.motionDetected or + self._device.windowState == HMIP_OPEN): + return STATE_ALARM_TRIGGERED + + if self._device.label == HMIP_ZONE_HOME: + return STATE_ALARM_ARMED_HOME + return STATE_ALARM_ARMED_AWAY + + return STATE_ALARM_DISARMED + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self._home.set_security_zones_activation(False, False) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + await self._home.set_security_zones_activation(True, False) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self._home.set_security_zones_activation(True, True) + + @property + def device_state_attributes(self): + """Return the state attributes of the alarm control device.""" + # The base class is loading the battery property, but device doesn't + # have this property - base class needs clean-up. + return None diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index c40e577ae4a570..54b05c464b546c 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -6,6 +6,7 @@ DOMAIN = 'homematicip_cloud' COMPONENTS = [ + 'alarm_control_panel', 'binary_sensor', 'climate', 'light', diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 5344773fde6595..476bed368d7967 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -65,8 +65,10 @@ async def test_hap_setup_works(aioclient_mock): assert await hap.async_setup() is True assert hap.home is home - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 5 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'alarm_control_panel') + assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ (entry, 'binary_sensor') @@ -104,10 +106,10 @@ async def test_hap_reset_unloads_entry_if_setup(): assert hap.home is home assert len(hass.services.async_register.mock_calls) == 0 - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 5 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 hass.config_entries.async_forward_entry_unload.return_value = \ mock_coro(True) await hap.async_reset() - assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 5 + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 6 From 6e3ec97acfc51a660e8257d18c5fecfc265eddd1 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 13 Jul 2018 00:19:13 -0700 Subject: [PATCH 099/147] Include request.path in legacy api password warning message (#15438) --- homeassistant/components/http/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index a232d9295a4d7f..2cc62dce38ed70 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -27,7 +27,8 @@ async def auth_middleware(request, handler): if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or DATA_API_PASSWORD in request.query): - _LOGGER.warning('Please use access_token instead api_password.') + _LOGGER.warning('Please change to use bearer token access %s', + request.path) legacy_auth = (not use_auth or support_legacy) and api_password if (hdrs.AUTHORIZATION in request.headers and From 23f1b49e55c4e94a5fcd3ea488fda89a44243f40 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 13 Jul 2018 12:37:03 +0300 Subject: [PATCH 100/147] Add python 3.8-dev to travis and tox (#15347) * Add Python 3.8-dev tox tests. * Allow failures on 3.8-dev * Allow failures on 3.8-dev take2 * Only run on pushes to dev --- .travis.yml | 11 ++++++++--- tox.ini | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5b3c43ec8c8bae..0a3d710810cf69 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,9 +19,14 @@ matrix: - python: "3.7" env: TOXENV=py37 dist: xenial - # allow_failures: - # - python: "3.5" - # env: TOXENV=typing + - python: "3.8-dev" + env: TOXENV=py38 + dist: xenial + if: branch = dev AND type = push + allow_failures: + - python: "3.8-dev" + env: TOXENV=py38 + dist: xenial cache: directories: diff --git a/tox.ini b/tox.ini index 578a431febf9cd..4ed68fddf3716f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, py37, lint, pylint, typing +envlist = py35, py36, py37, py38, lint, pylint, typing skip_missing_interpreters = True [testenv] From b6ca03ce47a7bc47049a5fa39b31380e07bd9f26 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jul 2018 11:43:08 +0200 Subject: [PATCH 101/147] Reorg auth (#15443) --- homeassistant/auth.py | 613 ------------------ homeassistant/auth/__init__.py | 191 ++++++ homeassistant/auth/auth_store.py | 213 ++++++ homeassistant/auth/const.py | 4 + homeassistant/auth/models.py | 75 +++ homeassistant/auth/providers/__init__.py | 147 +++++ .../providers}/homeassistant.py | 13 +- .../providers}/insecure_example.py | 10 +- .../providers}/legacy_api_password.py | 11 +- homeassistant/auth/util.py | 13 + homeassistant/auth_providers/__init__.py | 1 - homeassistant/components/rachio.py | 2 +- homeassistant/config.py | 3 +- homeassistant/scripts/auth.py | 2 +- tests/auth/__init__.py | 1 + .../providers}/__init__.py | 0 .../providers}/test_homeassistant.py | 2 +- .../providers}/test_insecure_example.py | 8 +- .../providers}/test_legacy_api_password.py | 5 +- tests/{test_auth.py => auth/test_init.py} | 27 +- tests/common.py | 5 +- tests/components/http/test_auth.py | 2 +- tests/scripts/test_auth.py | 2 +- 23 files changed, 698 insertions(+), 652 deletions(-) delete mode 100644 homeassistant/auth.py create mode 100644 homeassistant/auth/__init__.py create mode 100644 homeassistant/auth/auth_store.py create mode 100644 homeassistant/auth/const.py create mode 100644 homeassistant/auth/models.py create mode 100644 homeassistant/auth/providers/__init__.py rename homeassistant/{auth_providers => auth/providers}/homeassistant.py (93%) rename homeassistant/{auth_providers => auth/providers}/insecure_example.py (93%) rename homeassistant/{auth_providers => auth/providers}/legacy_api_password.py (91%) create mode 100644 homeassistant/auth/util.py delete mode 100644 homeassistant/auth_providers/__init__.py create mode 100644 tests/auth/__init__.py rename tests/{auth_providers => auth/providers}/__init__.py (100%) rename tests/{auth_providers => auth/providers}/test_homeassistant.py (97%) rename tests/{auth_providers => auth/providers}/test_insecure_example.py (91%) rename tests/{auth_providers => auth/providers}/test_legacy_api_password.py (94%) rename tests/{test_auth.py => auth/test_init.py} (91%) diff --git a/homeassistant/auth.py b/homeassistant/auth.py deleted file mode 100644 index c84f5e83ef0aa5..00000000000000 --- a/homeassistant/auth.py +++ /dev/null @@ -1,613 +0,0 @@ -"""Provide an authentication layer for Home Assistant.""" -import asyncio -import binascii -import importlib -import logging -import os -import uuid -from collections import OrderedDict -from datetime import datetime, timedelta - -import attr -import voluptuous as vol -from voluptuous.humanize import humanize_error - -from homeassistant import data_entry_flow, requirements -from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID -from homeassistant.core import callback -from homeassistant.util import dt as dt_util -from homeassistant.util.decorator import Registry - -_LOGGER = logging.getLogger(__name__) - -STORAGE_VERSION = 1 -STORAGE_KEY = 'auth' - -AUTH_PROVIDERS = Registry() - -AUTH_PROVIDER_SCHEMA = vol.Schema({ - vol.Required(CONF_TYPE): str, - vol.Optional(CONF_NAME): str, - # Specify ID if you have two auth providers for same type. - vol.Optional(CONF_ID): str, -}, extra=vol.ALLOW_EXTRA) - -ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) -DATA_REQS = 'auth_reqs_processed' - - -def generate_secret(entropy: int = 32) -> str: - """Generate a secret. - - Backport of secrets.token_hex from Python 3.6 - - Event loop friendly. - """ - return binascii.hexlify(os.urandom(entropy)).decode('ascii') - - -class AuthProvider: - """Provider of user authentication.""" - - DEFAULT_TITLE = 'Unnamed auth provider' - - initialized = False - - def __init__(self, hass, store, config): - """Initialize an auth provider.""" - self.hass = hass - self.store = store - self.config = config - - @property - def id(self): # pylint: disable=invalid-name - """Return id of the auth provider. - - Optional, can be None. - """ - return self.config.get(CONF_ID) - - @property - def type(self): - """Return type of the provider.""" - return self.config[CONF_TYPE] - - @property - def name(self): - """Return the name of the auth provider.""" - return self.config.get(CONF_NAME, self.DEFAULT_TITLE) - - async def async_credentials(self): - """Return all credentials of this provider.""" - users = await self.store.async_get_users() - return [ - credentials - for user in users - for credentials in user.credentials - if (credentials.auth_provider_type == self.type and - credentials.auth_provider_id == self.id) - ] - - @callback - def async_create_credentials(self, data): - """Create credentials.""" - return Credentials( - auth_provider_type=self.type, - auth_provider_id=self.id, - data=data, - ) - - # Implement by extending class - - async def async_initialize(self): - """Initialize the auth provider. - - Optional. - """ - - async def async_credential_flow(self): - """Return the data flow for logging in with auth provider.""" - raise NotImplementedError - - async def async_get_or_create_credentials(self, flow_result): - """Get credentials based on the flow result.""" - raise NotImplementedError - - async def async_user_meta_for_credentials(self, credentials): - """Return extra user metadata for credentials. - - Will be used to populate info when creating a new user. - """ - return {} - - -@attr.s(slots=True) -class User: - """A user.""" - - name = attr.ib(type=str) - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - is_owner = attr.ib(type=bool, default=False) - is_active = attr.ib(type=bool, default=False) - system_generated = attr.ib(type=bool, default=False) - - # List of credentials of a user. - credentials = attr.ib(type=list, default=attr.Factory(list), cmp=False) - - # Tokens associated with a user. - refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict), cmp=False) - - -@attr.s(slots=True) -class RefreshToken: - """RefreshToken for a user to grant new access tokens.""" - - user = attr.ib(type=User) - client_id = attr.ib(type=str) - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - access_token_expiration = attr.ib(type=timedelta, - default=ACCESS_TOKEN_EXPIRATION) - token = attr.ib(type=str, - default=attr.Factory(lambda: generate_secret(64))) - access_tokens = attr.ib(type=list, default=attr.Factory(list), cmp=False) - - -@attr.s(slots=True) -class AccessToken: - """Access token to access the API. - - These will only ever be stored in memory and not be persisted. - """ - - refresh_token = attr.ib(type=RefreshToken) - created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - token = attr.ib(type=str, - default=attr.Factory(generate_secret)) - - @property - def expired(self): - """Return if this token has expired.""" - expires = self.created_at + self.refresh_token.access_token_expiration - return dt_util.utcnow() > expires - - -@attr.s(slots=True) -class Credentials: - """Credentials for a user on an auth provider.""" - - auth_provider_type = attr.ib(type=str) - auth_provider_id = attr.ib(type=str) - - # Allow the auth provider to store data to represent their auth. - data = attr.ib(type=dict) - - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - is_new = attr.ib(type=bool, default=True) - - -async def load_auth_provider_module(hass, provider): - """Load an auth provider.""" - try: - module = importlib.import_module( - 'homeassistant.auth_providers.{}'.format(provider)) - except ImportError: - _LOGGER.warning('Unable to find auth provider %s', provider) - return None - - if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): - return module - - processed = hass.data.get(DATA_REQS) - - if processed is None: - processed = hass.data[DATA_REQS] = set() - elif provider in processed: - return module - - req_success = await requirements.async_process_requirements( - hass, 'auth provider {}'.format(provider), module.REQUIREMENTS) - - if not req_success: - return None - - return module - - -async def auth_manager_from_config(hass, provider_configs): - """Initialize an auth manager from config.""" - store = AuthStore(hass) - if provider_configs: - providers = await asyncio.gather( - *[_auth_provider_from_config(hass, store, config) - for config in provider_configs]) - else: - providers = [] - # So returned auth providers are in same order as config - provider_hash = OrderedDict() - for provider in providers: - if provider is None: - continue - - key = (provider.type, provider.id) - - if key in provider_hash: - _LOGGER.error( - 'Found duplicate provider: %s. Please add unique IDs if you ' - 'want to have the same provider twice.', key) - continue - - provider_hash[key] = provider - manager = AuthManager(hass, store, provider_hash) - return manager - - -async def _auth_provider_from_config(hass, store, config): - """Initialize an auth provider from a config.""" - provider_name = config[CONF_TYPE] - module = await load_auth_provider_module(hass, provider_name) - - if module is None: - return None - - try: - config = module.CONFIG_SCHEMA(config) - except vol.Invalid as err: - _LOGGER.error('Invalid configuration for auth provider %s: %s', - provider_name, humanize_error(config, err)) - return None - - return AUTH_PROVIDERS[provider_name](hass, store, config) - - -class AuthManager: - """Manage the authentication for Home Assistant.""" - - def __init__(self, hass, store, providers): - """Initialize the auth manager.""" - self._store = store - self._providers = providers - self.login_flow = data_entry_flow.FlowManager( - hass, self._async_create_login_flow, - self._async_finish_login_flow) - self._access_tokens = {} - - @property - def active(self): - """Return if any auth providers are registered.""" - return bool(self._providers) - - @property - def support_legacy(self): - """ - Return if legacy_api_password auth providers are registered. - - Should be removed when we removed legacy_api_password auth providers. - """ - for provider_type, _ in self._providers: - if provider_type == 'legacy_api_password': - return True - return False - - @property - def async_auth_providers(self): - """Return a list of available auth providers.""" - return self._providers.values() - - async def async_get_user(self, user_id): - """Retrieve a user.""" - return await self._store.async_get_user(user_id) - - async def async_create_system_user(self, name): - """Create a system user.""" - return await self._store.async_create_user( - name=name, - system_generated=True, - is_active=True, - ) - - async def async_get_or_create_user(self, credentials): - """Get or create a user.""" - if not credentials.is_new: - for user in await self._store.async_get_users(): - for creds in user.credentials: - if creds.id == credentials.id: - return user - - raise ValueError('Unable to find the user.') - - auth_provider = self._async_get_auth_provider(credentials) - info = await auth_provider.async_user_meta_for_credentials( - credentials) - - kwargs = { - 'credentials': credentials, - 'name': info.get('name') - } - - # Make owner and activate user if it's the first user. - if await self._store.async_get_users(): - kwargs['is_owner'] = False - kwargs['is_active'] = False - else: - kwargs['is_owner'] = True - kwargs['is_active'] = True - - return await self._store.async_create_user(**kwargs) - - async def async_link_user(self, user, credentials): - """Link credentials to an existing user.""" - await self._store.async_link_user(user, credentials) - - async def async_remove_user(self, user): - """Remove a user.""" - await self._store.async_remove_user(user) - - async def async_create_refresh_token(self, user, client_id=None): - """Create a new refresh token for a user.""" - if not user.is_active: - raise ValueError('User is not active') - - if user.system_generated and client_id is not None: - raise ValueError( - 'System generated users cannot have refresh tokens connected ' - 'to a client.') - - if not user.system_generated and client_id is None: - raise ValueError('Client is required to generate a refresh token.') - - return await self._store.async_create_refresh_token(user, client_id) - - async def async_get_refresh_token(self, token): - """Get refresh token by token.""" - return await self._store.async_get_refresh_token(token) - - @callback - def async_create_access_token(self, refresh_token): - """Create a new access token.""" - access_token = AccessToken(refresh_token=refresh_token) - self._access_tokens[access_token.token] = access_token - return access_token - - @callback - def async_get_access_token(self, token): - """Get an access token.""" - tkn = self._access_tokens.get(token) - - if tkn is None: - return None - - if tkn.expired: - self._access_tokens.pop(token) - return None - - return tkn - - async def _async_create_login_flow(self, handler, *, source, data): - """Create a login flow.""" - auth_provider = self._providers[handler] - - if not auth_provider.initialized: - auth_provider.initialized = True - await auth_provider.async_initialize() - - return await auth_provider.async_credential_flow() - - async def _async_finish_login_flow(self, result): - """Result of a credential login flow.""" - if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - return None - - auth_provider = self._providers[result['handler']] - return await auth_provider.async_get_or_create_credentials( - result['data']) - - @callback - def _async_get_auth_provider(self, credentials): - """Helper to get auth provider from a set of credentials.""" - auth_provider_key = (credentials.auth_provider_type, - credentials.auth_provider_id) - return self._providers[auth_provider_key] - - -class AuthStore: - """Stores authentication info. - - Any mutation to an object should happen inside the auth store. - - The auth store is lazy. It won't load the data from disk until a method is - called that needs it. - """ - - def __init__(self, hass): - """Initialize the auth store.""" - self.hass = hass - self._users = None - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - - async def async_get_users(self): - """Retrieve all users.""" - if self._users is None: - await self.async_load() - - return list(self._users.values()) - - async def async_get_user(self, user_id): - """Retrieve a user by id.""" - if self._users is None: - await self.async_load() - - return self._users.get(user_id) - - async def async_create_user(self, name, is_owner=None, is_active=None, - system_generated=None, credentials=None): - """Create a new user.""" - if self._users is None: - await self.async_load() - - kwargs = { - 'name': name - } - - if is_owner is not None: - kwargs['is_owner'] = is_owner - - if is_active is not None: - kwargs['is_active'] = is_active - - if system_generated is not None: - kwargs['system_generated'] = system_generated - - new_user = User(**kwargs) - - self._users[new_user.id] = new_user - - if credentials is None: - await self.async_save() - return new_user - - # Saving is done inside the link. - await self.async_link_user(new_user, credentials) - return new_user - - async def async_link_user(self, user, credentials): - """Add credentials to an existing user.""" - user.credentials.append(credentials) - await self.async_save() - credentials.is_new = False - - async def async_remove_user(self, user): - """Remove a user.""" - self._users.pop(user.id) - await self.async_save() - - async def async_create_refresh_token(self, user, client_id=None): - """Create a new token for a user.""" - refresh_token = RefreshToken(user=user, client_id=client_id) - user.refresh_tokens[refresh_token.token] = refresh_token - await self.async_save() - return refresh_token - - async def async_get_refresh_token(self, token): - """Get refresh token by token.""" - if self._users is None: - await self.async_load() - - for user in self._users.values(): - refresh_token = user.refresh_tokens.get(token) - if refresh_token is not None: - return refresh_token - - return None - - async def async_load(self): - """Load the users.""" - data = await self._store.async_load() - - # Make sure that we're not overriding data if 2 loads happened at the - # same time - if self._users is not None: - return - - if data is None: - self._users = {} - return - - users = { - user_dict['id']: User(**user_dict) for user_dict in data['users'] - } - - for cred_dict in data['credentials']: - users[cred_dict['user_id']].credentials.append(Credentials( - id=cred_dict['id'], - is_new=False, - auth_provider_type=cred_dict['auth_provider_type'], - auth_provider_id=cred_dict['auth_provider_id'], - data=cred_dict['data'], - )) - - refresh_tokens = {} - - for rt_dict in data['refresh_tokens']: - token = RefreshToken( - id=rt_dict['id'], - user=users[rt_dict['user_id']], - client_id=rt_dict['client_id'], - created_at=dt_util.parse_datetime(rt_dict['created_at']), - access_token_expiration=timedelta( - seconds=rt_dict['access_token_expiration']), - token=rt_dict['token'], - ) - refresh_tokens[token.id] = token - users[rt_dict['user_id']].refresh_tokens[token.token] = token - - for ac_dict in data['access_tokens']: - refresh_token = refresh_tokens[ac_dict['refresh_token_id']] - token = AccessToken( - refresh_token=refresh_token, - created_at=dt_util.parse_datetime(ac_dict['created_at']), - token=ac_dict['token'], - ) - refresh_token.access_tokens.append(token) - - self._users = users - - async def async_save(self): - """Save users.""" - users = [ - { - 'id': user.id, - 'is_owner': user.is_owner, - 'is_active': user.is_active, - 'name': user.name, - 'system_generated': user.system_generated, - } - for user in self._users.values() - ] - - credentials = [ - { - 'id': credential.id, - 'user_id': user.id, - 'auth_provider_type': credential.auth_provider_type, - 'auth_provider_id': credential.auth_provider_id, - 'data': credential.data, - } - for user in self._users.values() - for credential in user.credentials - ] - - refresh_tokens = [ - { - 'id': refresh_token.id, - 'user_id': user.id, - 'client_id': refresh_token.client_id, - 'created_at': refresh_token.created_at.isoformat(), - 'access_token_expiration': - refresh_token.access_token_expiration.total_seconds(), - 'token': refresh_token.token, - } - for user in self._users.values() - for refresh_token in user.refresh_tokens.values() - ] - - access_tokens = [ - { - 'id': user.id, - 'refresh_token_id': refresh_token.id, - 'created_at': access_token.created_at.isoformat(), - 'token': access_token.token, - } - for user in self._users.values() - for refresh_token in user.refresh_tokens.values() - for access_token in refresh_token.access_tokens - ] - - data = { - 'users': users, - 'credentials': credentials, - 'access_tokens': access_tokens, - 'refresh_tokens': refresh_tokens, - } - - await self._store.async_save(data, delay=1) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py new file mode 100644 index 00000000000000..c5db65586b1caf --- /dev/null +++ b/homeassistant/auth/__init__.py @@ -0,0 +1,191 @@ +"""Provide an authentication layer for Home Assistant.""" +import asyncio +import logging +from collections import OrderedDict + +from homeassistant import data_entry_flow +from homeassistant.core import callback + +from . import models +from . import auth_store +from .providers import auth_provider_from_config + +_LOGGER = logging.getLogger(__name__) + + +async def auth_manager_from_config(hass, provider_configs): + """Initialize an auth manager from config.""" + store = auth_store.AuthStore(hass) + if provider_configs: + providers = await asyncio.gather( + *[auth_provider_from_config(hass, store, config) + for config in provider_configs]) + else: + providers = [] + # So returned auth providers are in same order as config + provider_hash = OrderedDict() + for provider in providers: + if provider is None: + continue + + key = (provider.type, provider.id) + + if key in provider_hash: + _LOGGER.error( + 'Found duplicate provider: %s. Please add unique IDs if you ' + 'want to have the same provider twice.', key) + continue + + provider_hash[key] = provider + manager = AuthManager(hass, store, provider_hash) + return manager + + +class AuthManager: + """Manage the authentication for Home Assistant.""" + + def __init__(self, hass, store, providers): + """Initialize the auth manager.""" + self._store = store + self._providers = providers + self.login_flow = data_entry_flow.FlowManager( + hass, self._async_create_login_flow, + self._async_finish_login_flow) + self._access_tokens = {} + + @property + def active(self): + """Return if any auth providers are registered.""" + return bool(self._providers) + + @property + def support_legacy(self): + """ + Return if legacy_api_password auth providers are registered. + + Should be removed when we removed legacy_api_password auth providers. + """ + for provider_type, _ in self._providers: + if provider_type == 'legacy_api_password': + return True + return False + + @property + def async_auth_providers(self): + """Return a list of available auth providers.""" + return self._providers.values() + + async def async_get_user(self, user_id): + """Retrieve a user.""" + return await self._store.async_get_user(user_id) + + async def async_create_system_user(self, name): + """Create a system user.""" + return await self._store.async_create_user( + name=name, + system_generated=True, + is_active=True, + ) + + async def async_get_or_create_user(self, credentials): + """Get or create a user.""" + if not credentials.is_new: + for user in await self._store.async_get_users(): + for creds in user.credentials: + if creds.id == credentials.id: + return user + + raise ValueError('Unable to find the user.') + + auth_provider = self._async_get_auth_provider(credentials) + info = await auth_provider.async_user_meta_for_credentials( + credentials) + + kwargs = { + 'credentials': credentials, + 'name': info.get('name') + } + + # Make owner and activate user if it's the first user. + if await self._store.async_get_users(): + kwargs['is_owner'] = False + kwargs['is_active'] = False + else: + kwargs['is_owner'] = True + kwargs['is_active'] = True + + return await self._store.async_create_user(**kwargs) + + async def async_link_user(self, user, credentials): + """Link credentials to an existing user.""" + await self._store.async_link_user(user, credentials) + + async def async_remove_user(self, user): + """Remove a user.""" + await self._store.async_remove_user(user) + + async def async_create_refresh_token(self, user, client_id=None): + """Create a new refresh token for a user.""" + if not user.is_active: + raise ValueError('User is not active') + + if user.system_generated and client_id is not None: + raise ValueError( + 'System generated users cannot have refresh tokens connected ' + 'to a client.') + + if not user.system_generated and client_id is None: + raise ValueError('Client is required to generate a refresh token.') + + return await self._store.async_create_refresh_token(user, client_id) + + async def async_get_refresh_token(self, token): + """Get refresh token by token.""" + return await self._store.async_get_refresh_token(token) + + @callback + def async_create_access_token(self, refresh_token): + """Create a new access token.""" + access_token = models.AccessToken(refresh_token=refresh_token) + self._access_tokens[access_token.token] = access_token + return access_token + + @callback + def async_get_access_token(self, token): + """Get an access token.""" + tkn = self._access_tokens.get(token) + + if tkn is None: + return None + + if tkn.expired: + self._access_tokens.pop(token) + return None + + return tkn + + async def _async_create_login_flow(self, handler, *, source, data): + """Create a login flow.""" + auth_provider = self._providers[handler] + + if not auth_provider.initialized: + auth_provider.initialized = True + await auth_provider.async_initialize() + + return await auth_provider.async_credential_flow() + + async def _async_finish_login_flow(self, result): + """Result of a credential login flow.""" + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return None + + auth_provider = self._providers[result['handler']] + return await auth_provider.async_get_or_create_credentials( + result['data']) + + @callback + def _async_get_auth_provider(self, credentials): + """Helper to get auth provider from a set of credentials.""" + auth_provider_key = (credentials.auth_provider_type, + credentials.auth_provider_id) + return self._providers[auth_provider_key] diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py new file mode 100644 index 00000000000000..691e561f22ff5c --- /dev/null +++ b/homeassistant/auth/auth_store.py @@ -0,0 +1,213 @@ +"""Storage for auth models.""" +from datetime import timedelta + +from homeassistant.util import dt as dt_util + +from . import models + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth' + + +class AuthStore: + """Stores authentication info. + + Any mutation to an object should happen inside the auth store. + + The auth store is lazy. It won't load the data from disk until a method is + called that needs it. + """ + + def __init__(self, hass): + """Initialize the auth store.""" + self.hass = hass + self._users = None + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + async def async_get_users(self): + """Retrieve all users.""" + if self._users is None: + await self.async_load() + + return list(self._users.values()) + + async def async_get_user(self, user_id): + """Retrieve a user by id.""" + if self._users is None: + await self.async_load() + + return self._users.get(user_id) + + async def async_create_user(self, name, is_owner=None, is_active=None, + system_generated=None, credentials=None): + """Create a new user.""" + if self._users is None: + await self.async_load() + + kwargs = { + 'name': name + } + + if is_owner is not None: + kwargs['is_owner'] = is_owner + + if is_active is not None: + kwargs['is_active'] = is_active + + if system_generated is not None: + kwargs['system_generated'] = system_generated + + new_user = models.User(**kwargs) + + self._users[new_user.id] = new_user + + if credentials is None: + await self.async_save() + return new_user + + # Saving is done inside the link. + await self.async_link_user(new_user, credentials) + return new_user + + async def async_link_user(self, user, credentials): + """Add credentials to an existing user.""" + user.credentials.append(credentials) + await self.async_save() + credentials.is_new = False + + async def async_remove_user(self, user): + """Remove a user.""" + self._users.pop(user.id) + await self.async_save() + + async def async_create_refresh_token(self, user, client_id=None): + """Create a new token for a user.""" + refresh_token = models.RefreshToken(user=user, client_id=client_id) + user.refresh_tokens[refresh_token.token] = refresh_token + await self.async_save() + return refresh_token + + async def async_get_refresh_token(self, token): + """Get refresh token by token.""" + if self._users is None: + await self.async_load() + + for user in self._users.values(): + refresh_token = user.refresh_tokens.get(token) + if refresh_token is not None: + return refresh_token + + return None + + async def async_load(self): + """Load the users.""" + data = await self._store.async_load() + + # Make sure that we're not overriding data if 2 loads happened at the + # same time + if self._users is not None: + return + + if data is None: + self._users = {} + return + + users = { + user_dict['id']: models.User(**user_dict) + for user_dict in data['users'] + } + + for cred_dict in data['credentials']: + users[cred_dict['user_id']].credentials.append(models.Credentials( + id=cred_dict['id'], + is_new=False, + auth_provider_type=cred_dict['auth_provider_type'], + auth_provider_id=cred_dict['auth_provider_id'], + data=cred_dict['data'], + )) + + refresh_tokens = {} + + for rt_dict in data['refresh_tokens']: + token = models.RefreshToken( + id=rt_dict['id'], + user=users[rt_dict['user_id']], + client_id=rt_dict['client_id'], + created_at=dt_util.parse_datetime(rt_dict['created_at']), + access_token_expiration=timedelta( + seconds=rt_dict['access_token_expiration']), + token=rt_dict['token'], + ) + refresh_tokens[token.id] = token + users[rt_dict['user_id']].refresh_tokens[token.token] = token + + for ac_dict in data['access_tokens']: + refresh_token = refresh_tokens[ac_dict['refresh_token_id']] + token = models.AccessToken( + refresh_token=refresh_token, + created_at=dt_util.parse_datetime(ac_dict['created_at']), + token=ac_dict['token'], + ) + refresh_token.access_tokens.append(token) + + self._users = users + + async def async_save(self): + """Save users.""" + users = [ + { + 'id': user.id, + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'name': user.name, + 'system_generated': user.system_generated, + } + for user in self._users.values() + ] + + credentials = [ + { + 'id': credential.id, + 'user_id': user.id, + 'auth_provider_type': credential.auth_provider_type, + 'auth_provider_id': credential.auth_provider_id, + 'data': credential.data, + } + for user in self._users.values() + for credential in user.credentials + ] + + refresh_tokens = [ + { + 'id': refresh_token.id, + 'user_id': user.id, + 'client_id': refresh_token.client_id, + 'created_at': refresh_token.created_at.isoformat(), + 'access_token_expiration': + refresh_token.access_token_expiration.total_seconds(), + 'token': refresh_token.token, + } + for user in self._users.values() + for refresh_token in user.refresh_tokens.values() + ] + + access_tokens = [ + { + 'id': user.id, + 'refresh_token_id': refresh_token.id, + 'created_at': access_token.created_at.isoformat(), + 'token': access_token.token, + } + for user in self._users.values() + for refresh_token in user.refresh_tokens.values() + for access_token in refresh_token.access_tokens + ] + + data = { + 'users': users, + 'credentials': credentials, + 'access_tokens': access_tokens, + 'refresh_tokens': refresh_tokens, + } + + await self._store.async_save(data, delay=1) diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py new file mode 100644 index 00000000000000..082d8966275670 --- /dev/null +++ b/homeassistant/auth/const.py @@ -0,0 +1,4 @@ +"""Constants for the auth module.""" +from datetime import timedelta + +ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py new file mode 100644 index 00000000000000..38e054dc7cf71a --- /dev/null +++ b/homeassistant/auth/models.py @@ -0,0 +1,75 @@ +"""Auth models.""" +from datetime import datetime, timedelta +import uuid + +import attr + +from homeassistant.util import dt as dt_util + +from .const import ACCESS_TOKEN_EXPIRATION +from .util import generate_secret + + +@attr.s(slots=True) +class User: + """A user.""" + + name = attr.ib(type=str) + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + is_owner = attr.ib(type=bool, default=False) + is_active = attr.ib(type=bool, default=False) + system_generated = attr.ib(type=bool, default=False) + + # List of credentials of a user. + credentials = attr.ib(type=list, default=attr.Factory(list), cmp=False) + + # Tokens associated with a user. + refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict), cmp=False) + + +@attr.s(slots=True) +class RefreshToken: + """RefreshToken for a user to grant new access tokens.""" + + user = attr.ib(type=User) + client_id = attr.ib(type=str) + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) + access_token_expiration = attr.ib(type=timedelta, + default=ACCESS_TOKEN_EXPIRATION) + token = attr.ib(type=str, + default=attr.Factory(lambda: generate_secret(64))) + access_tokens = attr.ib(type=list, default=attr.Factory(list), cmp=False) + + +@attr.s(slots=True) +class AccessToken: + """Access token to access the API. + + These will only ever be stored in memory and not be persisted. + """ + + refresh_token = attr.ib(type=RefreshToken) + created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) + token = attr.ib(type=str, + default=attr.Factory(generate_secret)) + + @property + def expired(self): + """Return if this token has expired.""" + expires = self.created_at + self.refresh_token.access_token_expiration + return dt_util.utcnow() > expires + + +@attr.s(slots=True) +class Credentials: + """Credentials for a user on an auth provider.""" + + auth_provider_type = attr.ib(type=str) + auth_provider_id = attr.ib(type=str) + + # Allow the auth provider to store data to represent their auth. + data = attr.ib(type=dict) + + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + is_new = attr.ib(type=bool, default=True) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py new file mode 100644 index 00000000000000..d6630383ff2159 --- /dev/null +++ b/homeassistant/auth/providers/__init__.py @@ -0,0 +1,147 @@ +"""Auth providers for Home Assistant.""" +import importlib +import logging + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant import requirements +from homeassistant.core import callback +from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID +from homeassistant.util.decorator import Registry + +from homeassistant.auth.models import Credentials + +_LOGGER = logging.getLogger(__name__) +DATA_REQS = 'auth_prov_reqs_processed' + +AUTH_PROVIDERS = Registry() + +AUTH_PROVIDER_SCHEMA = vol.Schema({ + vol.Required(CONF_TYPE): str, + vol.Optional(CONF_NAME): str, + # Specify ID if you have two auth providers for same type. + vol.Optional(CONF_ID): str, +}, extra=vol.ALLOW_EXTRA) + + +async def auth_provider_from_config(hass, store, config): + """Initialize an auth provider from a config.""" + provider_name = config[CONF_TYPE] + module = await load_auth_provider_module(hass, provider_name) + + if module is None: + return None + + try: + config = module.CONFIG_SCHEMA(config) + except vol.Invalid as err: + _LOGGER.error('Invalid configuration for auth provider %s: %s', + provider_name, humanize_error(config, err)) + return None + + return AUTH_PROVIDERS[provider_name](hass, store, config) + + +async def load_auth_provider_module(hass, provider): + """Load an auth provider.""" + try: + module = importlib.import_module( + 'homeassistant.auth.providers.{}'.format(provider)) + except ImportError: + _LOGGER.warning('Unable to find auth provider %s', provider) + return None + + if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + return module + + processed = hass.data.get(DATA_REQS) + + if processed is None: + processed = hass.data[DATA_REQS] = set() + elif provider in processed: + return module + + req_success = await requirements.async_process_requirements( + hass, 'auth provider {}'.format(provider), module.REQUIREMENTS) + + if not req_success: + return None + + processed.add(provider) + return module + + +class AuthProvider: + """Provider of user authentication.""" + + DEFAULT_TITLE = 'Unnamed auth provider' + + initialized = False + + def __init__(self, hass, store, config): + """Initialize an auth provider.""" + self.hass = hass + self.store = store + self.config = config + + @property + def id(self): # pylint: disable=invalid-name + """Return id of the auth provider. + + Optional, can be None. + """ + return self.config.get(CONF_ID) + + @property + def type(self): + """Return type of the provider.""" + return self.config[CONF_TYPE] + + @property + def name(self): + """Return the name of the auth provider.""" + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + + async def async_credentials(self): + """Return all credentials of this provider.""" + users = await self.store.async_get_users() + return [ + credentials + for user in users + for credentials in user.credentials + if (credentials.auth_provider_type == self.type and + credentials.auth_provider_id == self.id) + ] + + @callback + def async_create_credentials(self, data): + """Create credentials.""" + return Credentials( + auth_provider_type=self.type, + auth_provider_id=self.id, + data=data, + ) + + # Implement by extending class + + async def async_initialize(self): + """Initialize the auth provider. + + Optional. + """ + + async def async_credential_flow(self): + """Return the data flow for logging in with auth provider.""" + raise NotImplementedError + + async def async_get_or_create_credentials(self, flow_result): + """Get credentials based on the flow result.""" + raise NotImplementedError + + async def async_user_meta_for_credentials(self, credentials): + """Return extra user metadata for credentials. + + Will be used to populate info when creating a new user. + """ + return {} diff --git a/homeassistant/auth_providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py similarity index 93% rename from homeassistant/auth_providers/homeassistant.py rename to homeassistant/auth/providers/homeassistant.py index c4d2021f6ce054..fa6878da065743 100644 --- a/homeassistant/auth_providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -6,14 +6,17 @@ import voluptuous as vol -from homeassistant import auth, data_entry_flow +from homeassistant import data_entry_flow from homeassistant.exceptions import HomeAssistantError +from homeassistant.auth.util import generate_secret + +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS STORAGE_VERSION = 1 STORAGE_KEY = 'auth_provider.homeassistant' -CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ }, extra=vol.PREVENT_EXTRA) @@ -43,7 +46,7 @@ async def async_load(self): if data is None: data = { - 'salt': auth.generate_secret(), + 'salt': generate_secret(), 'users': [] } @@ -112,8 +115,8 @@ async def async_save(self): await self._store.async_save(self._data) -@auth.AUTH_PROVIDERS.register('homeassistant') -class HassAuthProvider(auth.AuthProvider): +@AUTH_PROVIDERS.register('homeassistant') +class HassAuthProvider(AuthProvider): """Auth provider based on a local storage of users in HASS config dir.""" DEFAULT_TITLE = 'Home Assistant Local' diff --git a/homeassistant/auth_providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py similarity index 93% rename from homeassistant/auth_providers/insecure_example.py rename to homeassistant/auth/providers/insecure_example.py index a8e8cd0cb0e121..e06b16177a1241 100644 --- a/homeassistant/auth_providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -5,9 +5,11 @@ import voluptuous as vol from homeassistant.exceptions import HomeAssistantError -from homeassistant import auth, data_entry_flow +from homeassistant import data_entry_flow from homeassistant.core import callback +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS + USER_SCHEMA = vol.Schema({ vol.Required('username'): str, @@ -16,7 +18,7 @@ }) -CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ vol.Required('users'): [USER_SCHEMA] }, extra=vol.PREVENT_EXTRA) @@ -25,8 +27,8 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -@auth.AUTH_PROVIDERS.register('insecure_example') -class ExampleAuthProvider(auth.AuthProvider): +@AUTH_PROVIDERS.register('insecure_example') +class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" async def async_credential_flow(self): diff --git a/homeassistant/auth_providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py similarity index 91% rename from homeassistant/auth_providers/legacy_api_password.py rename to homeassistant/auth/providers/legacy_api_password.py index 510cc4d02792fe..57c05e3bdc86bc 100644 --- a/homeassistant/auth_providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -9,15 +9,18 @@ import voluptuous as vol from homeassistant.exceptions import HomeAssistantError -from homeassistant import auth, data_entry_flow +from homeassistant import data_entry_flow from homeassistant.core import callback +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS + + USER_SCHEMA = vol.Schema({ vol.Required('username'): str, }) -CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ }, extra=vol.PREVENT_EXTRA) LEGACY_USER = 'homeassistant' @@ -27,8 +30,8 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -@auth.AUTH_PROVIDERS.register('legacy_api_password') -class LegacyApiPasswordAuthProvider(auth.AuthProvider): +@AUTH_PROVIDERS.register('legacy_api_password') +class LegacyApiPasswordAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" DEFAULT_TITLE = 'Legacy API Password' diff --git a/homeassistant/auth/util.py b/homeassistant/auth/util.py new file mode 100644 index 00000000000000..402caae4618d0f --- /dev/null +++ b/homeassistant/auth/util.py @@ -0,0 +1,13 @@ +"""Auth utils.""" +import binascii +import os + + +def generate_secret(entropy: int = 32) -> str: + """Generate a secret. + + Backport of secrets.token_hex from Python 3.6 + + Event loop friendly. + """ + return binascii.hexlify(os.urandom(entropy)).decode('ascii') diff --git a/homeassistant/auth_providers/__init__.py b/homeassistant/auth_providers/__init__.py deleted file mode 100644 index 4705e7580ca447..00000000000000 --- a/homeassistant/auth_providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Auth providers for Home Assistant.""" diff --git a/homeassistant/components/rachio.py b/homeassistant/components/rachio.py index b3b2d05e933485..3a804c50c74a5a 100644 --- a/homeassistant/components/rachio.py +++ b/homeassistant/components/rachio.py @@ -10,7 +10,7 @@ from aiohttp import web import voluptuous as vol -from homeassistant.auth import generate_secret +from homeassistant.auth.util import generate_secret from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/config.py b/homeassistant/config.py index 52ff0e19c598b2..48632ccab83723 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -13,6 +13,7 @@ from voluptuous.humanize import humanize_error from homeassistant import auth +from homeassistant.auth import providers as auth_providers from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM, @@ -159,7 +160,7 @@ vol.All(cv.ensure_list, [vol.IsDir()]), vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, vol.Optional(CONF_AUTH_PROVIDERS): - vol.All(cv.ensure_list, [auth.AUTH_PROVIDER_SCHEMA]) + vol.All(cv.ensure_list, [auth_providers.AUTH_PROVIDER_SCHEMA]) }) diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index dacdc7b18e24ed..aa39e9f66df278 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant from homeassistant.config import get_default_config_dir -from homeassistant.auth_providers import homeassistant as hass_auth +from homeassistant.auth.providers import homeassistant as hass_auth def run(args): diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 00000000000000..48a99324b304e2 --- /dev/null +++ b/tests/auth/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant auth module.""" diff --git a/tests/auth_providers/__init__.py b/tests/auth/providers/__init__.py similarity index 100% rename from tests/auth_providers/__init__.py rename to tests/auth/providers/__init__.py diff --git a/tests/auth_providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py similarity index 97% rename from tests/auth_providers/test_homeassistant.py rename to tests/auth/providers/test_homeassistant.py index 1d9a29bf48b7be..98701ba2857e60 100644 --- a/tests/auth_providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -2,7 +2,7 @@ import pytest from homeassistant import data_entry_flow -from homeassistant.auth_providers import homeassistant as hass_auth +from homeassistant.auth.providers import homeassistant as hass_auth @pytest.fixture diff --git a/tests/auth_providers/test_insecure_example.py b/tests/auth/providers/test_insecure_example.py similarity index 91% rename from tests/auth_providers/test_insecure_example.py rename to tests/auth/providers/test_insecure_example.py index cb0bab4afed04f..8e8c9738756567 100644 --- a/tests/auth_providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -4,8 +4,8 @@ import pytest -from homeassistant import auth -from homeassistant.auth_providers import insecure_example +from homeassistant.auth import auth_store, models as auth_models +from homeassistant.auth.providers import insecure_example from tests.common import mock_coro @@ -13,7 +13,7 @@ @pytest.fixture def store(hass): """Mock store.""" - return auth.AuthStore(hass) + return auth_store.AuthStore(hass) @pytest.fixture @@ -45,7 +45,7 @@ async def test_create_new_credential(provider): async def test_match_existing_credentials(store, provider): """See if we match existing users.""" - existing = auth.Credentials( + existing = auth_models.Credentials( id=uuid.uuid4(), auth_provider_type='insecure_example', auth_provider_id=None, diff --git a/tests/auth_providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py similarity index 94% rename from tests/auth_providers/test_legacy_api_password.py rename to tests/auth/providers/test_legacy_api_password.py index 3a186a0454c6db..007e37b90c4e3e 100644 --- a/tests/auth_providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -4,13 +4,14 @@ import pytest from homeassistant import auth -from homeassistant.auth_providers import legacy_api_password +from homeassistant.auth import auth_store +from homeassistant.auth.providers import legacy_api_password @pytest.fixture def store(hass): """Mock store.""" - return auth.AuthStore(hass) + return auth_store.AuthStore(hass) @pytest.fixture diff --git a/tests/test_auth.py b/tests/auth/test_init.py similarity index 91% rename from tests/test_auth.py rename to tests/auth/test_init.py index a53c5aaec99b8c..805369a6da8721 100644 --- a/tests/test_auth.py +++ b/tests/auth/test_init.py @@ -5,6 +5,8 @@ import pytest from homeassistant import auth, data_entry_flow +from homeassistant.auth import ( + models as auth_models, auth_store, const as auth_const) from homeassistant.util import dt as dt_util from tests.common import ( MockUser, ensure_auth_manager_loaded, flush_store, CLIENT_ID) @@ -101,7 +103,7 @@ async def test_login_as_existing_user(mock_hass): is_active=False, name='Not user', ).add_to_auth_manager(manager) - user.credentials.append(auth.Credentials( + user.credentials.append(auth_models.Credentials( id='mock-id2', auth_provider_type='insecure_example', auth_provider_id=None, @@ -116,7 +118,7 @@ async def test_login_as_existing_user(mock_hass): is_active=False, name='Paulus', ).add_to_auth_manager(manager) - user.credentials.append(auth.Credentials( + user.credentials.append(auth_models.Credentials( id='mock-id', auth_provider_type='insecure_example', auth_provider_id=None, @@ -203,7 +205,7 @@ async def test_saving_loading(hass, hass_storage): await flush_store(manager._store._store) - store2 = auth.AuthStore(hass) + store2 = auth_store.AuthStore(hass) users = await store2.async_get_users() assert len(users) == 1 assert users[0] == user @@ -211,23 +213,25 @@ async def test_saving_loading(hass, hass_storage): def test_access_token_expired(): """Test that the expired property on access tokens work.""" - refresh_token = auth.RefreshToken( + refresh_token = auth_models.RefreshToken( user=None, client_id='bla' ) - access_token = auth.AccessToken( + access_token = auth_models.AccessToken( refresh_token=refresh_token ) assert access_token.expired is False - with patch('homeassistant.auth.dt_util.utcnow', - return_value=dt_util.utcnow() + auth.ACCESS_TOKEN_EXPIRATION): + with patch('homeassistant.util.dt.utcnow', + return_value=dt_util.utcnow() + + auth_const.ACCESS_TOKEN_EXPIRATION): assert access_token.expired is True - almost_exp = dt_util.utcnow() + auth.ACCESS_TOKEN_EXPIRATION - timedelta(1) - with patch('homeassistant.auth.dt_util.utcnow', return_value=almost_exp): + almost_exp = \ + dt_util.utcnow() + auth_const.ACCESS_TOKEN_EXPIRATION - timedelta(1) + with patch('homeassistant.util.dt.utcnow', return_value=almost_exp): assert access_token.expired is False @@ -242,8 +246,9 @@ async def test_cannot_retrieve_expired_access_token(hass): access_token = manager.async_create_access_token(refresh_token) assert manager.async_get_access_token(access_token.token) is access_token - with patch('homeassistant.auth.dt_util.utcnow', - return_value=dt_util.utcnow() + auth.ACCESS_TOKEN_EXPIRATION): + with patch('homeassistant.util.dt.utcnow', + return_value=dt_util.utcnow() + + auth_const.ACCESS_TOKEN_EXPIRATION): assert manager.async_get_access_token(access_token.token) is None # Even with unpatched time, it should have been removed from manager diff --git a/tests/common.py b/tests/common.py index 98a3b0a6074e2b..b3da5e0d0987ba 100644 --- a/tests/common.py +++ b/tests/common.py @@ -12,6 +12,7 @@ from contextlib import contextmanager from homeassistant import auth, core as ha, data_entry_flow, config_entries +from homeassistant.auth import models as auth_models, auth_store from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -114,7 +115,7 @@ def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) hass.config.async_load = Mock() - store = auth.AuthStore(hass) + store = auth_store.AuthStore(hass) hass.auth = auth.AuthManager(hass, store, {}) ensure_auth_manager_loaded(hass.auth) INSTANCES.append(hass) @@ -308,7 +309,7 @@ def mock_registry(hass, mock_entries=None): return registry -class MockUser(auth.User): +class MockUser(auth_models.User): """Mock a user in Home Assistant.""" def __init__(self, id='mock-id', is_owner=True, is_active=True, diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 3e5eed4c924b55..19785958422836 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -7,7 +7,7 @@ from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -from homeassistant.auth import AccessToken, RefreshToken +from homeassistant.auth.models import AccessToken, RefreshToken from homeassistant.components.http.auth import setup_auth from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index e6aa7893f33b0a..cd0524eb032660 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -4,7 +4,7 @@ import pytest from homeassistant.scripts import auth as script_auth -from homeassistant.auth_providers import homeassistant as hass_auth +from homeassistant.auth.providers import homeassistant as hass_auth @pytest.fixture From c2fe0d0120f28c6f33cf5384633d7b6a65946620 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 13 Jul 2018 13:24:51 +0300 Subject: [PATCH 102/147] Make typing checks more strict (#14429) ## Description: Make typing checks more strict: add `--strict-optional` flag that forbids implicit None return type. This flag will become default in the next version of mypy (0.600) Add `homeassistant/util/` to checked dirs. ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** --- homeassistant/__main__.py | 4 ++-- homeassistant/bootstrap.py | 20 ++++++++++-------- homeassistant/components/logbook.py | 2 +- homeassistant/components/panel_iframe.py | 7 ++----- homeassistant/core.py | 24 ++++++++++++++++++---- homeassistant/helpers/storage.py | 5 ++--- homeassistant/loader.py | 13 +++++++++--- homeassistant/setup.py | 4 ++-- homeassistant/util/color.py | 4 ++-- homeassistant/util/dt.py | 26 ++++++++++++------------ homeassistant/util/json.py | 10 ++++----- homeassistant/util/unit_system.py | 6 +++--- homeassistant/util/yaml.py | 6 ++++-- tests/test_bootstrap.py | 3 ++- tests/test_core.py | 12 +++++++++++ tests/util/test_yaml.py | 16 +++++++++++++++ tox.ini | 2 +- 17 files changed, 107 insertions(+), 57 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 7d3d2d2af88853..496308598dce20 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -241,7 +241,7 @@ def cmdline() -> List[str]: def setup_and_run_hass(config_dir: str, - args: argparse.Namespace) -> Optional[int]: + args: argparse.Namespace) -> int: """Set up HASS and run.""" from homeassistant import bootstrap @@ -274,7 +274,7 @@ def setup_and_run_hass(config_dir: str, log_no_color=args.log_no_color) if hass is None: - return None + return -1 if args.open_ui: # Imported here to avoid importing asyncio before monkey patch diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0a71c2887b13d8..a190aea9fa896d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -28,9 +28,8 @@ # hass.data key for logging information. DATA_LOGGING = 'logging' -FIRST_INIT_COMPONENT = set(( - 'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', - 'introduction', 'frontend', 'history')) +FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', + 'logger', 'introduction', 'frontend', 'history'} def from_config_dict(config: Dict[str, Any], @@ -95,7 +94,8 @@ async def async_from_config_dict(config: Dict[str, Any], conf_util.async_log_exception(ex, 'homeassistant', core_config, hass) return None - await hass.async_add_job(conf_util.process_ha_config_upgrade, hass) + await hass.async_add_executor_job( + conf_util.process_ha_config_upgrade, hass) hass.config.skip_pip = skip_pip if skip_pip: @@ -137,7 +137,7 @@ async def async_from_config_dict(config: Dict[str, Any], for component in components: if component not in FIRST_INIT_COMPONENT: continue - hass.async_add_job(async_setup_component(hass, component, config)) + hass.async_create_task(async_setup_component(hass, component, config)) await hass.async_block_till_done() @@ -145,7 +145,7 @@ async def async_from_config_dict(config: Dict[str, Any], for component in components: if component in FIRST_INIT_COMPONENT: continue - hass.async_add_job(async_setup_component(hass, component, config)) + hass.async_create_task(async_setup_component(hass, component, config)) await hass.async_block_till_done() @@ -162,7 +162,8 @@ def from_config_file(config_path: str, skip_pip: bool = True, log_rotate_days: Any = None, log_file: Any = None, - log_no_color: bool = False): + log_no_color: bool = False)\ + -> Optional[core.HomeAssistant]: """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -187,7 +188,8 @@ async def async_from_config_file(config_path: str, skip_pip: bool = True, log_rotate_days: Any = None, log_file: Any = None, - log_no_color: bool = False): + log_no_color: bool = False)\ + -> Optional[core.HomeAssistant]: """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -204,7 +206,7 @@ async def async_from_config_file(config_path: str, log_no_color) try: - config_dict = await hass.async_add_job( + config_dict = await hass.async_add_executor_job( conf_util.load_yaml_config_file, config_path) except HomeAssistantError as err: _LOGGER.error("Error loading %s: %s", config_path, err) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index e2d02acc61c019..eb2e83912210c8 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -83,7 +83,7 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None): hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data) -async def setup(hass, config): +async def async_setup(hass, config): """Listen for download events to download files.""" @callback def log_message(service): diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 4574437bac94ea..86594b74995b8c 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -4,8 +4,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/panel_iframe/ """ -import asyncio - import voluptuous as vol from homeassistant.const import (CONF_ICON, CONF_URL) @@ -34,11 +32,10 @@ }})}, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def setup(hass, config): +async def async_setup(hass, config): """Set up the iFrame frontend panels.""" for url_path, info in config[DOMAIN].items(): - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), url_path, {'url': info[CONF_URL]}) diff --git a/homeassistant/core.py b/homeassistant/core.py index e09501729139bb..c7aa04910bd758 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -17,7 +17,8 @@ from time import monotonic from types import MappingProxyType -from typing import Optional, Any, Callable, List, TypeVar, Dict # NOQA +from typing import ( # NOQA + Optional, Any, Callable, List, TypeVar, Dict, Coroutine) from async_timeout import timeout import voluptuous as vol @@ -205,8 +206,8 @@ def add_job(self, target: Callable[..., None], *args: Any) -> None: def async_add_job( self, target: Callable[..., Any], - *args: Any) -> Optional[asyncio.tasks.Task]: - """Add a job from within the eventloop. + *args: Any) -> Optional[asyncio.Future]: + """Add a job from within the event loop. This method must be run in the event loop. @@ -230,11 +231,26 @@ def async_add_job( return task + @callback + def async_create_task(self, target: Coroutine) -> asyncio.tasks.Task: + """Create a task from within the eventloop. + + This method must be run in the event loop. + + target: target to call. + """ + task = self.loop.create_task(target) + + if self._track_task: + self._pending_tasks.append(task) + + return task + @callback def async_add_executor_job( self, target: Callable[..., Any], - *args: Any) -> asyncio.tasks.Task: + *args: Any) -> asyncio.Future: """Add an executor job from within the event loop.""" task = self.loop.run_in_executor(None, target, *args) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 962074ec3affd0..a68b489868d1ce 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -80,11 +80,10 @@ async def _async_load(self): data = self._data else: data = await self.hass.async_add_executor_job( - json.load_json, self.path, None) + json.load_json, self.path) - if data is None: + if data == {}: return None - if data['version'] == self.version: stored = data['data'] else: diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 153d00f92fce56..b22271d6eb532c 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -16,14 +16,20 @@ import sys from types import ModuleType -from typing import Optional, Set +# pylint: disable=unused-import +from typing import Dict, List, Optional, Sequence, Set, TYPE_CHECKING # NOQA from homeassistant.const import PLATFORM_FORMAT from homeassistant.util import OrderedSet +# Typing imports that create a circular dependency +# pylint: disable=using-constant-test,unused-import +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant # NOQA + PREPARED = False -DEPENDENCY_BLACKLIST = set(('config',)) +DEPENDENCY_BLACKLIST = {'config'} _LOGGER = logging.getLogger(__name__) @@ -33,7 +39,8 @@ PACKAGE_COMPONENTS = 'homeassistant.components' -def set_component(hass, comp_name: str, component: ModuleType) -> None: +def set_component(hass, # type: HomeAssistant + comp_name: str, component: Optional[ModuleType]) -> None: """Set a component in the cache. Async friendly. diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 1664653f2a7ee8..5398cfde96311e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -50,7 +50,7 @@ async def async_setup_component(hass: core.HomeAssistant, domain: str, if setup_tasks is None: setup_tasks = hass.data[DATA_SETUP] = {} - task = setup_tasks[domain] = hass.async_add_job( + task = setup_tasks[domain] = hass.async_create_task( _async_setup_component(hass, domain, config)) return await task @@ -142,7 +142,7 @@ def log_error(msg, link=True): result = await component.async_setup( # type: ignore hass, processed_config) else: - result = await hass.async_add_job( + result = await hass.async_add_executor_job( component.setup, hass, processed_config) # type: ignore except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during setup of component %s", domain) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index d2138f4293c59c..a26f7014444cfd 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -267,8 +267,8 @@ def color_xy_brightness_to_RGB(vX: float, vY: float, def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: """Convert a hsb into its rgb representation.""" if fS == 0: - fV = fB * 255 - return (fV, fV, fV) + fV = int(fB * 255) + return fV, fV, fV r = g = b = 0 h = fH / 60 diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 37b917baa2efa7..0f07a90e9bbde4 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -6,9 +6,11 @@ from typing import Any, Dict, Union, Optional, Tuple # NOQA import pytz +import pytz.exceptions as pytzexceptions DATE_STR_FORMAT = "%Y-%m-%d" -UTC = DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo +UTC = pytz.utc +DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo # Copyright (c) Django Software Foundation and individual contributors. @@ -42,7 +44,7 @@ def get_time_zone(time_zone_str: str) -> Optional[dt.tzinfo]: """ try: return pytz.timezone(time_zone_str) - except pytz.exceptions.UnknownTimeZoneError: + except pytzexceptions.UnknownTimeZoneError: return None @@ -64,7 +66,7 @@ def as_utc(dattim: dt.datetime) -> dt.datetime: if dattim.tzinfo == UTC: return dattim elif dattim.tzinfo is None: - dattim = DEFAULT_TIME_ZONE.localize(dattim) + dattim = DEFAULT_TIME_ZONE.localize(dattim) # type: ignore return dattim.astimezone(UTC) @@ -92,7 +94,7 @@ def as_local(dattim: dt.datetime) -> dt.datetime: def utc_from_timestamp(timestamp: float) -> dt.datetime: """Return a UTC time from a timestamp.""" - return dt.datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) + return UTC.localize(dt.datetime.utcfromtimestamp(timestamp)) def start_of_local_day(dt_or_d: @@ -102,13 +104,14 @@ def start_of_local_day(dt_or_d: date = now().date() # type: dt.date elif isinstance(dt_or_d, dt.datetime): date = dt_or_d.date() - return DEFAULT_TIME_ZONE.localize(dt.datetime.combine(date, dt.time())) + return DEFAULT_TIME_ZONE.localize(dt.datetime.combine( # type: ignore + date, dt.time())) # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. # https://github.com/django/django/blob/master/LICENSE -def parse_datetime(dt_str: str) -> dt.datetime: +def parse_datetime(dt_str: str) -> Optional[dt.datetime]: """Parse a string and return a datetime.datetime. This function supports time zone offsets. When the input contains one, @@ -134,14 +137,12 @@ def parse_datetime(dt_str: str) -> dt.datetime: if tzinfo_str[0] == '-': offset = -offset tzinfo = dt.timezone(offset) - else: - tzinfo = None kws = {k: int(v) for k, v in kws.items() if v is not None} kws['tzinfo'] = tzinfo return dt.datetime(**kws) -def parse_date(dt_str: str) -> dt.date: +def parse_date(dt_str: str) -> Optional[dt.date]: """Convert a date string to a date object.""" try: return dt.datetime.strptime(dt_str, DATE_STR_FORMAT).date() @@ -180,9 +181,8 @@ def get_age(date: dt.datetime) -> str: def formatn(number: int, unit: str) -> str: """Add "unit" if it's plural.""" if number == 1: - return "1 %s" % unit - elif number > 1: - return "%d %ss" % (number, unit) + return '1 {}'.format(unit) + return '{:d} {}s'.format(number, unit) def q_n_r(first: int, second: int) -> Tuple[int, int]: """Return quotient and remaining.""" @@ -210,4 +210,4 @@ def q_n_r(first: int, second: int) -> Tuple[int, int]: if minute > 0: return formatn(minute, 'minute') - return formatn(second, 'second') if second > 0 else "0 seconds" + return formatn(second, 'second') diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 0e53342b0cafb8..74feb779dcd4cb 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -8,8 +8,6 @@ _LOGGER = logging.getLogger(__name__) -_UNDEFINED = object() - class SerializationError(HomeAssistantError): """Error serializing the data to JSON.""" @@ -19,7 +17,7 @@ class WriteError(HomeAssistantError): """Error writing the data.""" -def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ +def load_json(filename: str, default: Union[List, Dict, None] = None) \ -> Union[List, Dict]: """Load JSON data from a file and return as dict or list. @@ -37,7 +35,7 @@ def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ except OSError as error: _LOGGER.exception('JSON file reading failed: %s', filename) raise HomeAssistantError(error) - return {} if default is _UNDEFINED else default + return {} if default is None else default def save_json(filename: str, data: Union[List, Dict]): @@ -46,9 +44,9 @@ def save_json(filename: str, data: Union[List, Dict]): Returns True on success. """ try: - data = json.dumps(data, sort_keys=True, indent=4) + json_data = json.dumps(data, sort_keys=True, indent=4) with open(filename, 'w', encoding='utf-8') as fdesc: - fdesc.write(data) + fdesc.write(json_data) except TypeError as error: _LOGGER.exception('Failed to serialize to JSON: %s', filename) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index ecef1087747079..4cc0fff96b94ac 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -86,11 +86,11 @@ def __init__(self: object, name: str, temperature: str, length: str, self.volume_unit = volume @property - def is_metric(self: object) -> bool: + def is_metric(self) -> bool: """Determine if this is the metric unit system.""" return self.name == CONF_UNIT_SYSTEM_METRIC - def temperature(self: object, temperature: float, from_unit: str) -> float: + def temperature(self, temperature: float, from_unit: str) -> float: """Convert the given temperature to this unit system.""" if not isinstance(temperature, Number): raise TypeError( @@ -99,7 +99,7 @@ def temperature(self: object, temperature: float, from_unit: str) -> float: return temperature_util.convert(temperature, from_unit, self.temperature_unit) - def length(self: object, length: float, from_unit: str) -> float: + def length(self, length: float, from_unit: str) -> float: """Convert the given length to this unit system.""" if not isinstance(length, Number): raise TypeError('{} is not a numeric value.'.format(str(length))) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 0e7befd5e9ebb9..298d52722a58c8 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -57,7 +57,7 @@ def compose_node(self, parent: yaml.nodes.Node, index) -> yaml.nodes.Node: last_line = self.line # type: int node = super(SafeLineLoader, self).compose_node(parent, index) # type: yaml.nodes.Node - node.__line__ = last_line + 1 + node.__line__ = last_line + 1 # type: ignore return node @@ -69,7 +69,7 @@ def load_yaml(fname: str) -> Union[List, Dict]: # We convert that to an empty dict return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict() except yaml.YAMLError as exc: - _LOGGER.error(exc) + _LOGGER.error(str(exc)) raise HomeAssistantError(exc) except UnicodeDecodeError as exc: _LOGGER.error("Unable to read file %s: %s", fname, exc) @@ -232,6 +232,8 @@ def _load_secret_yaml(secret_path: str) -> Dict: _LOGGER.debug('Loading %s', secret_path) try: secrets = load_yaml(secret_path) + if not isinstance(secrets, dict): + raise HomeAssistantError('Secrets is not a dictionary') if 'logger' in secrets: logger = str(secrets['logger']).lower() if logger == 'debug': diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index e329f835f84b71..4f258bc2b099d4 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -81,7 +81,8 @@ def test_from_config_dict_not_mount_deps_folder(loop): async def test_async_from_config_file_not_mount_deps_folder(loop): """Test that we not mount the deps folder inside async_from_config_file.""" - hass = Mock(async_add_job=Mock(side_effect=lambda *args: mock_coro())) + hass = Mock( + async_add_executor_job=Mock(side_effect=lambda *args: mock_coro())) with patch('homeassistant.bootstrap.is_virtual_env', return_value=False), \ patch('homeassistant.bootstrap.async_enable_logging', diff --git a/tests/test_core.py b/tests/test_core.py index 4abce180093d00..7633c820d2da9f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -67,6 +67,18 @@ def test_async_add_job_add_threaded_job_to_pool(mock_iscoro): assert len(hass.loop.run_in_executor.mock_calls) == 1 +@patch('asyncio.iscoroutine', return_value=True) +def test_async_create_task_schedule_coroutine(mock_iscoro): + """Test that we schedule coroutines and add jobs to the job pool.""" + hass = MagicMock() + job = MagicMock() + + ha.HomeAssistant.async_create_task(hass, job) + assert len(hass.loop.call_soon.mock_calls) == 0 + assert len(hass.loop.create_task.mock_calls) == 1 + assert len(hass.add_job.mock_calls) == 0 + + def test_async_run_job_calls_callback(): """Test that the callback annotation is respected.""" hass = MagicMock() diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 734f4b548b91f4..d08915b348bba3 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -411,6 +411,22 @@ def test_bad_logger_value(self, mock_error): assert mock_error.call_count == 1, \ "Expected an error about logger: value" + def test_secrets_are_not_dict(self): + """Did secrets handle non-dict file.""" + FILES[self._secret_path] = ( + '- http_pw: pwhttp\n' + ' comp1_un: un1\n' + ' comp1_pw: pw1\n') + yaml.clear_secret_cache() + with self.assertRaises(HomeAssistantError): + load_yaml(self._yaml_path, + 'http:\n' + ' api_password: !secret http_pw\n' + 'component:\n' + ' username: !secret comp1_un\n' + ' password: !secret comp1_pw\n' + '') + def test_representing_yaml_loaded_data(): """Test we can represent YAML loaded data.""" diff --git a/tox.ini b/tox.ini index 4ed68fddf3716f..6e22f2a5e95757 100644 --- a/tox.ini +++ b/tox.ini @@ -42,4 +42,4 @@ whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - /bin/bash -c 'mypy --ignore-missing-imports --follow-imports=silent homeassistant/*.py' + /bin/bash -c 'mypy --ignore-missing-imports --follow-imports=silent --strict-optional --warn-unused-ignores homeassistant/*.py homeassistant/util/' From a6ba5ec1c85396b8142bbdfdfbf20597da59aec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 13 Jul 2018 14:49:24 +0300 Subject: [PATCH 103/147] upgrade-mypy (#14904) * Upgrade mypy to 0.600 * Upgrade mypy to 0.610 * Typing improvements * remove unneeded or * remove merge artifact * Update loader.py --- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index c8d3be81468e0c..db53699379cf32 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 -mypy==0.590 +mypy==0.610 pydocstyle==1.1.1 pylint==1.9.2 pytest-aiohttp==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1daaa106e99549..a4a8bd03b3ff38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 -mypy==0.590 +mypy==0.610 pydocstyle==1.1.1 pylint==1.9.2 pytest-aiohttp==0.3.0 From 84858f5c19a3a9ff25b9176652607ae5b56e8bfb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jul 2018 15:05:55 +0200 Subject: [PATCH 104/147] Fix comment formatting (#15447) --- homeassistant/components/device_tracker/asuswrt.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index bea02143d72a6b..710a07f77d38a8 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -311,12 +311,11 @@ def connect(self): super().connect() - def disconnect(self): \ - # pylint: disable=broad-except + def disconnect(self): """Disconnect the current SSH connection.""" try: self._ssh.logout() - except Exception: + except Exception: # pylint: disable=broad-except pass finally: self._ssh = None @@ -379,12 +378,11 @@ def connect(self): super().connect() - def disconnect(self): \ - # pylint: disable=broad-except + def disconnect(self): """Disconnect the current Telnet connection.""" try: self._telnet.write('exit\n'.encode('ascii')) - except Exception: + except Exception: # pylint: disable=broad-except pass super().disconnect() From 70fe463ef0b696f3b4f14086e2462a0ee13b3a6e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jul 2018 15:31:20 +0200 Subject: [PATCH 105/147] User management (#15420) * User management * Lint * Fix dict * Reuse data instance * OrderedDict all the way --- homeassistant/auth/__init__.py | 45 +++- homeassistant/auth/auth_store.py | 29 ++- homeassistant/auth/providers/__init__.py | 8 - homeassistant/auth/providers/homeassistant.py | 67 ++++- homeassistant/components/auth/__init__.py | 2 +- homeassistant/components/camera/__init__.py | 4 +- homeassistant/components/config/__init__.py | 4 + homeassistant/components/config/auth.py | 113 +++++++++ .../config/auth_provider_homeassistant.py | 120 +++++++++ homeassistant/components/http/auth.py | 5 + homeassistant/components/websocket_api.py | 21 +- homeassistant/scripts/auth.py | 53 +++- tests/auth/providers/test_homeassistant.py | 31 ++- tests/auth/test_init.py | 2 +- tests/common.py | 36 ++- tests/components/config/test_auth.py | 211 ++++++++++++++++ .../test_auth_provider_homeassistant.py | 229 ++++++++++++++++++ tests/components/conftest.py | 17 +- tests/components/http/test_auth.py | 41 ++-- tests/conftest.py | 2 +- tests/scripts/test_auth.py | 58 +++-- 21 files changed, 982 insertions(+), 116 deletions(-) create mode 100644 homeassistant/components/config/auth.py create mode 100644 homeassistant/components/config/auth_provider_homeassistant.py create mode 100644 tests/components/config/test_auth.py create mode 100644 tests/components/config/test_auth_provider_homeassistant.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index c5db65586b1caf..fb35bd05c336f8 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -51,7 +51,7 @@ def __init__(self, hass, store, providers): self.login_flow = data_entry_flow.FlowManager( hass, self._async_create_login_flow, self._async_finish_login_flow) - self._access_tokens = {} + self._access_tokens = OrderedDict() @property def active(self): @@ -71,9 +71,13 @@ def support_legacy(self): return False @property - def async_auth_providers(self): + def auth_providers(self): """Return a list of available auth providers.""" - return self._providers.values() + return list(self._providers.values()) + + async def async_get_users(self): + """Retrieve all users.""" + return await self._store.async_get_users() async def async_get_user(self, user_id): """Retrieve a user.""" @@ -87,6 +91,13 @@ async def async_create_system_user(self, name): is_active=True, ) + async def async_create_user(self, name): + """Create a user.""" + return await self._store.async_create_user( + name=name, + is_active=True, + ) + async def async_get_or_create_user(self, credentials): """Get or create a user.""" if not credentials.is_new: @@ -98,6 +109,10 @@ async def async_get_or_create_user(self, credentials): raise ValueError('Unable to find the user.') auth_provider = self._async_get_auth_provider(credentials) + + if auth_provider is None: + raise RuntimeError('Credential with unknown provider encountered') + info = await auth_provider.async_user_meta_for_credentials( credentials) @@ -122,8 +137,26 @@ async def async_link_user(self, user, credentials): async def async_remove_user(self, user): """Remove a user.""" + tasks = [ + self.async_remove_credentials(credentials) + for credentials in user.credentials + ] + + if tasks: + await asyncio.wait(tasks) + await self._store.async_remove_user(user) + async def async_remove_credentials(self, credentials): + """Remove credentials.""" + provider = self._async_get_auth_provider(credentials) + + if (provider is not None and + hasattr(provider, 'async_will_remove_credentials')): + await provider.async_will_remove_credentials(credentials) + + await self._store.async_remove_credentials(credentials) + async def async_create_refresh_token(self, user, client_id=None): """Create a new refresh token for a user.""" if not user.is_active: @@ -168,10 +201,6 @@ async def _async_create_login_flow(self, handler, *, source, data): """Create a login flow.""" auth_provider = self._providers[handler] - if not auth_provider.initialized: - auth_provider.initialized = True - await auth_provider.async_initialize() - return await auth_provider.async_credential_flow() async def _async_finish_login_flow(self, result): @@ -188,4 +217,4 @@ def _async_get_auth_provider(self, credentials): """Helper to get auth provider from a set of credentials.""" auth_provider_key = (credentials.auth_provider_type, credentials.auth_provider_id) - return self._providers[auth_provider_key] + return self._providers.get(auth_provider_key) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 691e561f22ff5c..ebd61140ac1ef1 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -1,4 +1,5 @@ """Storage for auth models.""" +from collections import OrderedDict from datetime import timedelta from homeassistant.util import dt as dt_util @@ -80,6 +81,22 @@ async def async_remove_user(self, user): self._users.pop(user.id) await self.async_save() + async def async_remove_credentials(self, credentials): + """Remove credentials.""" + for user in self._users.values(): + found = None + + for index, cred in enumerate(user.credentials): + if cred is credentials: + found = index + break + + if found is not None: + user.credentials.pop(found) + break + + await self.async_save() + async def async_create_refresh_token(self, user, client_id=None): """Create a new token for a user.""" refresh_token = models.RefreshToken(user=user, client_id=client_id) @@ -108,14 +125,14 @@ async def async_load(self): if self._users is not None: return + users = OrderedDict() + if data is None: - self._users = {} + self._users = users return - users = { - user_dict['id']: models.User(**user_dict) - for user_dict in data['users'] - } + for user_dict in data['users']: + users[user_dict['id']] = models.User(**user_dict) for cred_dict in data['credentials']: users[cred_dict['user_id']].credentials.append(models.Credentials( @@ -126,7 +143,7 @@ async def async_load(self): data=cred_dict['data'], )) - refresh_tokens = {} + refresh_tokens = OrderedDict() for rt_dict in data['refresh_tokens']: token = models.RefreshToken( diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index d6630383ff2159..3769248fc05352 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -77,8 +77,6 @@ class AuthProvider: DEFAULT_TITLE = 'Unnamed auth provider' - initialized = False - def __init__(self, hass, store, config): """Initialize an auth provider.""" self.hass = hass @@ -125,12 +123,6 @@ def async_create_credentials(self, data): # Implement by extending class - async def async_initialize(self): - """Initialize the auth provider. - - Optional. - """ - async def async_credential_flow(self): """Return the data flow for logging in with auth provider.""" raise NotImplementedError diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index fa6878da065743..17a56bc5f423ea 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -7,6 +7,8 @@ import voluptuous as vol from homeassistant import data_entry_flow +from homeassistant.const import CONF_ID +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.auth.util import generate_secret @@ -16,8 +18,17 @@ STORAGE_VERSION = 1 STORAGE_KEY = 'auth_provider.homeassistant' -CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ -}, extra=vol.PREVENT_EXTRA) + +def _disallow_id(conf): + """Disallow ID in config.""" + if CONF_ID in conf: + raise vol.Invalid( + 'ID is not allowed for the homeassistant auth provider.') + + return conf + + +CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id) class InvalidAuth(HomeAssistantError): @@ -88,8 +99,8 @@ def hash_password(self, password, for_storage=False): hashed = base64.b64encode(hashed).decode() return hashed - def add_user(self, username, password): - """Add a user.""" + def add_auth(self, username, password): + """Add a new authenticated user/pass.""" if any(user['username'] == username for user in self.users): raise InvalidUser @@ -98,8 +109,22 @@ def add_user(self, username, password): 'password': self.hash_password(password, True), }) + @callback + def async_remove_auth(self, username): + """Remove authentication.""" + index = None + for i, user in enumerate(self.users): + if user['username'] == username: + index = i + break + + if index is None: + raise InvalidUser + + self.users.pop(index) + def change_password(self, username, new_password): - """Update the password of a user. + """Update the password. Raises InvalidUser if user cannot be found. """ @@ -121,16 +146,24 @@ class HassAuthProvider(AuthProvider): DEFAULT_TITLE = 'Home Assistant Local' + data = None + + async def async_initialize(self): + """Initialize the auth provider.""" + self.data = Data(self.hass) + await self.data.async_load() + async def async_credential_flow(self): """Return a flow to login.""" return LoginFlow(self) async def async_validate_login(self, username, password): """Helper to validate a username and password.""" - data = Data(self.hass) - await data.async_load() + if self.data is None: + await self.async_initialize() + await self.hass.async_add_executor_job( - data.validate_login, username, password) + self.data.validate_login, username, password) async def async_get_or_create_credentials(self, flow_result): """Get credentials based on the flow result.""" @@ -145,6 +178,24 @@ async def async_get_or_create_credentials(self, flow_result): 'username': username }) + async def async_user_meta_for_credentials(self, credentials): + """Get extra info for this credential.""" + return { + 'name': credentials.data['username'] + } + + async def async_will_remove_credentials(self, credentials): + """When credentials get removed, also remove the auth.""" + if self.data is None: + await self.async_initialize() + + try: + self.data.async_remove_auth(credentials.data['username']) + await self.data.async_save() + except InvalidUser: + # Can happen if somehow we didn't clean up a credential + pass + class LoginFlow(data_entry_flow.FlowHandler): """Handler for the login flow.""" diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 3e236876d6a7ae..1ead4cacdf0bac 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -152,7 +152,7 @@ async def get(self, request): 'name': provider.name, 'id': provider.id, 'type': provider.type, - } for provider in request.app['hass'].auth.async_auth_providers]) + } for provider in request.app['hass'].auth.auth_providers]) class LoginFlowIndexView(FlowManagerIndexView): diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 14550dab899d34..22354b51956bc5 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -66,8 +66,8 @@ WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail' SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - 'type': WS_TYPE_CAMERA_THUMBNAIL, - 'entity_id': cv.entity_id + vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL, + vol.Required('entity_id'): cv.entity_id }) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index b907d4b4217b37..581d8fc3f7b9f1 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -49,6 +49,10 @@ def component_loaded(event): tasks = [setup_panel(panel_name) for panel_name in SECTIONS] + if hass.auth.active: + tasks.append(setup_panel('auth')) + tasks.append(setup_panel('auth_provider_homeassistant')) + for panel_name in ON_DEMAND: if panel_name in hass.config.components: tasks.append(setup_panel(panel_name)) diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py new file mode 100644 index 00000000000000..6f00b03dedb948 --- /dev/null +++ b/homeassistant/components/config/auth.py @@ -0,0 +1,113 @@ +"""Offer API to configure Home Assistant auth.""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components import websocket_api + + +WS_TYPE_LIST = 'config/auth/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + +WS_TYPE_DELETE = 'config/auth/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('user_id'): str, +}) + +WS_TYPE_CREATE = 'config/auth/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('name'): str, +}) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list, + SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, + SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, + SCHEMA_WS_CREATE + ) + return True + + +@callback +@websocket_api.require_owner +def websocket_list(hass, connection, msg): + """Return a list of users.""" + async def send_users(): + """Send users.""" + result = [_user_info(u) for u in await hass.auth.async_get_users()] + + connection.send_message_outside( + websocket_api.result_message(msg['id'], result)) + + hass.async_add_job(send_users()) + + +@callback +@websocket_api.require_owner +def websocket_delete(hass, connection, msg): + """Delete a user.""" + async def delete_user(): + """Delete user.""" + if msg['user_id'] == connection.request.get('hass_user').id: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'no_delete_self', + 'Unable to delete your own account')) + return + + user = await hass.auth.async_get_user(msg['user_id']) + + if not user: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'not_found', 'User not found')) + return + + await hass.auth.async_remove_user(user) + + connection.send_message_outside( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(delete_user()) + + +@callback +@websocket_api.require_owner +def websocket_create(hass, connection, msg): + """Create a user.""" + async def create_user(): + """Create a user.""" + user = await hass.auth.async_create_user(msg['name']) + + connection.send_message_outside( + websocket_api.result_message(msg['id'], { + 'user': _user_info(user) + })) + + hass.async_add_job(create_user()) + + +def _user_info(user): + """Format a user.""" + return { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'system_generated': user.system_generated, + 'credentials': [ + { + 'type': c.auth_provider_type, + } for c in user.credentials + ] + } diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py new file mode 100644 index 00000000000000..fca03ad8fa9f01 --- /dev/null +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -0,0 +1,120 @@ +"""Offer API to configure the Home Assistant auth provider.""" +import voluptuous as vol + +from homeassistant.auth.providers import homeassistant as auth_ha +from homeassistant.core import callback +from homeassistant.components import websocket_api + + +WS_TYPE_CREATE = 'config/auth_provider/homeassistant/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('user_id'): str, + vol.Required('username'): str, + vol.Required('password'): str, +}) + +WS_TYPE_DELETE = 'config/auth_provider/homeassistant/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('username'): str, +}) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, + SCHEMA_WS_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, + SCHEMA_WS_DELETE + ) + return True + + +def _get_provider(hass): + """Get homeassistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == 'homeassistant': + return prv + + raise RuntimeError('Provider not found') + + +@callback +@websocket_api.require_owner +def websocket_create(hass, connection, msg): + """Create credentials and attach to a user.""" + async def create_creds(): + """Create credentials.""" + provider = _get_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_get_user(msg['user_id']) + + if user is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'not_found', 'User not found')) + return + + if user.system_generated: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'system_generated', + 'Cannot add credentials to a system generated user.')) + return + + try: + await hass.async_add_executor_job( + provider.data.add_auth, msg['username'], msg['password']) + except auth_ha.InvalidUser: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'username_exists', 'Username already exists')) + return + + credentials = await provider.async_get_or_create_credentials({ + 'username': msg['username'] + }) + await hass.auth.async_link_user(user, credentials) + + await provider.data.async_save() + connection.to_write.put_nowait(websocket_api.result_message(msg['id'])) + + hass.async_add_job(create_creds()) + + +@callback +@websocket_api.require_owner +def websocket_delete(hass, connection, msg): + """Delete username and related credential.""" + async def delete_creds(): + """Delete user credentials.""" + provider = _get_provider(hass) + await provider.async_initialize() + + credentials = await provider.async_get_or_create_credentials({ + 'username': msg['username'] + }) + + # if not new, an existing credential exists. + # Removing the credential will also remove the auth. + if not credentials.is_new: + await hass.auth.async_remove_credentials(credentials) + + connection.to_write.put_nowait( + websocket_api.result_message(msg['id'])) + return + + try: + provider.data.async_remove_auth(msg['username']) + await provider.data.async_save() + except auth_ha.InvalidUser: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'auth_not_found', 'Given username was not found.')) + return + + connection.to_write.put_nowait( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(delete_creds()) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 2cc62dce38ed70..46d77214160fcb 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -106,6 +106,11 @@ async def async_validate_auth_header(request, api_password=None): if access_token is None: return False + user = access_token.refresh_token.user + + if not user.is_active: + return False + request['hass_user'] = access_token.refresh_token.user return True diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index c26f68a2c29f0f..6cd1690904136f 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -7,7 +7,7 @@ import asyncio from concurrent import futures from contextlib import suppress -from functools import partial +from functools import partial, wraps import json import logging @@ -196,6 +196,23 @@ def async_register_command(hass, command, handler, schema): handlers[command] = (handler, schema) +def require_owner(func): + """Websocket decorator to require user to be an owner.""" + @wraps(func) + def with_owner(hass, connection, msg): + """Check owner and call function.""" + user = connection.request.get('hass_user') + + if user is None or not user.is_owner: + connection.to_write.put_nowait(error_message( + msg['id'], 'unauthorized', 'This command is for owners only.')) + return + + func(hass, connection, msg) + + return with_owner + + async def async_setup(hass, config): """Initialize the websocket API.""" hass.http.register_view(WebsocketAPIView) @@ -325,6 +342,8 @@ def handle_hass_stop(event): token = self.hass.auth.async_get_access_token( msg['access_token']) authenticated = token is not None + if authenticated: + request['hass_user'] = token.refresh_token.user elif ((not self.hass.auth.active or self.hass.auth.support_legacy) and diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index aa39e9f66df278..fea523c4117a18 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -1,8 +1,10 @@ """Script to manage users for the Home Assistant auth provider.""" import argparse import asyncio +import logging import os +from homeassistant.auth import auth_manager_from_config from homeassistant.core import HomeAssistant from homeassistant.config import get_default_config_dir from homeassistant.auth.providers import homeassistant as hass_auth @@ -42,16 +44,28 @@ def run(args): args = parser.parse_args(args) loop = asyncio.get_event_loop() hass = HomeAssistant(loop=loop) + loop.run_until_complete(run_command(hass, args)) + + # Triggers save on used storage helpers with delay (core auth) + logging.getLogger('homeassistant.core').setLevel(logging.WARNING) + loop.run_until_complete(hass.async_stop()) + + +async def run_command(hass, args): + """Run the command.""" hass.config.config_dir = os.path.join(os.getcwd(), args.config) - data = hass_auth.Data(hass) - loop.run_until_complete(data.async_load()) - loop.run_until_complete(args.func(data, args)) + hass.auth = await auth_manager_from_config(hass, [{ + 'type': 'homeassistant', + }]) + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await args.func(hass, provider, args) -async def list_users(data, args): +async def list_users(hass, provider, args): """List the users.""" count = 0 - for user in data.users: + for user in provider.data.users: count += 1 print(user['username']) @@ -59,27 +73,40 @@ async def list_users(data, args): print("Total users:", count) -async def add_user(data, args): +async def add_user(hass, provider, args): """Create a user.""" - data.add_user(args.username, args.password) - await data.async_save() + try: + provider.data.add_auth(args.username, args.password) + except hass_auth.InvalidUser: + print("Username already exists!") + return + + credentials = await provider.async_get_or_create_credentials({ + 'username': args.username + }) + + user = await hass.auth.async_create_user(args.username) + await hass.auth.async_link_user(user, credentials) + + # Save username/password + await provider.data.async_save() print("User created") -async def validate_login(data, args): +async def validate_login(hass, provider, args): """Validate a login.""" try: - data.validate_login(args.username, args.password) + provider.data.validate_login(args.username, args.password) print("Auth valid") except hass_auth.InvalidAuth: print("Auth invalid") -async def change_password(data, args): +async def change_password(hass, provider, args): """Change password.""" try: - data.change_password(args.username, args.new_password) - await data.async_save() + provider.data.change_password(args.username, args.new_password) + await provider.data.async_save() print("Password changed") except hass_auth.InvalidUser: print("User not found") diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index 98701ba2857e60..08fb63a3c72770 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,8 +1,11 @@ """Test the Home Assistant local auth provider.""" +from unittest.mock import Mock + import pytest from homeassistant import data_entry_flow -from homeassistant.auth.providers import homeassistant as hass_auth +from homeassistant.auth.providers import ( + auth_provider_from_config, homeassistant as hass_auth) @pytest.fixture @@ -15,15 +18,15 @@ def data(hass): async def test_adding_user(data, hass): """Test adding a user.""" - data.add_user('test-user', 'test-pass') + data.add_auth('test-user', 'test-pass') data.validate_login('test-user', 'test-pass') async def test_adding_user_duplicate_username(data, hass): """Test adding a user.""" - data.add_user('test-user', 'test-pass') + data.add_auth('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidUser): - data.add_user('test-user', 'other-pass') + data.add_auth('test-user', 'other-pass') async def test_validating_password_invalid_user(data, hass): @@ -34,7 +37,7 @@ async def test_validating_password_invalid_user(data, hass): async def test_validating_password_invalid_password(data, hass): """Test validating an invalid user.""" - data.add_user('test-user', 'test-pass') + data.add_auth('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidAuth): data.validate_login('test-user', 'invalid-pass') @@ -43,7 +46,7 @@ async def test_validating_password_invalid_password(data, hass): async def test_changing_password(data, hass): """Test adding a user.""" user = 'test-user' - data.add_user(user, 'test-pass') + data.add_auth(user, 'test-pass') data.change_password(user, 'new-pass') with pytest.raises(hass_auth.InvalidAuth): @@ -60,7 +63,7 @@ async def test_changing_password_raises_invalid_user(data, hass): async def test_login_flow_validates(data, hass): """Test login flow.""" - data.add_user('test-user', 'test-pass') + data.add_auth('test-user', 'test-pass') await data.async_save() provider = hass_auth.HassAuthProvider(hass, None, {}) @@ -91,11 +94,21 @@ async def test_login_flow_validates(data, hass): async def test_saving_loading(data, hass): """Test saving and loading JSON.""" - data.add_user('test-user', 'test-pass') - data.add_user('second-user', 'second-pass') + data.add_auth('test-user', 'test-pass') + data.add_auth('second-user', 'second-pass') await data.async_save() data = hass_auth.Data(hass) await data.async_load() data.validate_login('test-user', 'test-pass') data.validate_login('second-user', 'second-pass') + + +async def test_not_allow_set_id(): + """Test we are not allowed to set an ID in config.""" + hass = Mock() + provider = await auth_provider_from_config(hass, None, { + 'type': 'homeassistant', + 'id': 'invalid', + }) + assert provider is None diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 805369a6da8721..f7187fd49fd08a 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -46,7 +46,7 @@ async def test_auth_manager_from_config_validates_config_and_id(mock_hass): 'name': provider.name, 'id': provider.id, 'type': provider.type, - } for provider in manager.async_auth_providers] + } for provider in manager.auth_providers] assert providers == [{ 'name': 'Test Name', 'type': 'insecure_example', diff --git a/tests/common.py b/tests/common.py index b3da5e0d0987ba..b03d473e6f32ee 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,5 +1,6 @@ """Test the helper method for writing tests.""" import asyncio +from collections import OrderedDict from datetime import timedelta import functools as ft import json @@ -12,7 +13,8 @@ from contextlib import contextmanager from homeassistant import auth, core as ha, data_entry_flow, config_entries -from homeassistant.auth import models as auth_models, auth_store +from homeassistant.auth import ( + models as auth_models, auth_store, providers as auth_providers) from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -312,11 +314,12 @@ def mock_registry(hass, mock_entries=None): class MockUser(auth_models.User): """Mock a user in Home Assistant.""" - def __init__(self, id='mock-id', is_owner=True, is_active=True, - name='Mock User'): + def __init__(self, id='mock-id', is_owner=False, is_active=True, + name='Mock User', system_generated=False): """Initialize mock user.""" super().__init__( - id=id, is_owner=is_owner, is_active=is_active, name=name) + id=id, is_owner=is_owner, is_active=is_active, name=name, + system_generated=system_generated) def add_to_hass(self, hass): """Test helper to add entry to hass.""" @@ -329,12 +332,27 @@ def add_to_auth_manager(self, auth_mgr): return self +async def register_auth_provider(hass, config): + """Helper to register an auth provider.""" + provider = await auth_providers.auth_provider_from_config( + hass, hass.auth._store, config) + assert provider is not None, 'Invalid config specified' + key = (provider.type, provider.id) + providers = hass.auth._providers + + if key in providers: + raise ValueError('Provider already registered') + + providers[key] = provider + return provider + + @ha.callback def ensure_auth_manager_loaded(auth_mgr): """Ensure an auth manager is considered loaded.""" store = auth_mgr._store if store._users is None: - store._users = {} + store._users = OrderedDict() class MockModule(object): @@ -731,7 +749,13 @@ async def mock_async_load(store): if store.key not in data: return None - store._data = data.get(store.key) + mock_data = data.get(store.key) + + if 'data' not in mock_data or 'version' not in mock_data: + _LOGGER.error('Mock data needs "version" and "data"') + raise ValueError('Mock data needs "version" and "data"') + + store._data = mock_data # Route through original load so that we trigger migration loaded = await orig_load(store) diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py new file mode 100644 index 00000000000000..fe8f351955f8a5 --- /dev/null +++ b/tests/components/config/test_auth.py @@ -0,0 +1,211 @@ +"""Test config entries API.""" +from unittest.mock import PropertyMock, patch + +import pytest + +from homeassistant.auth import models as auth_models +from homeassistant.components.config import auth as auth_config + +from tests.common import MockUser, CLIENT_ID + + +@pytest.fixture(autouse=True) +def auth_active(hass): + """Mock that auth is active.""" + with patch('homeassistant.auth.AuthManager.active', + PropertyMock(return_value=True)): + yield + + +@pytest.fixture(autouse=True) +def setup_config(hass, aiohttp_client): + """Fixture that sets up the auth provider homeassistant module.""" + hass.loop.run_until_complete(auth_config.async_setup(hass)) + + +async def test_list_requires_owner(hass, hass_ws_client, hass_access_token): + """Test get users requires auth.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_LIST, + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_list(hass, hass_ws_client): + """Test get users.""" + owner = MockUser( + id='abc', + name='Test Owner', + is_owner=True, + ).add_to_hass(hass) + + owner.credentials.append(auth_models.Credentials( + auth_provider_type='homeassistant', + auth_provider_id=None, + data={}, + )) + + system = MockUser( + id='efg', + name='Test Hass.io', + system_generated=True + ).add_to_hass(hass) + + inactive = MockUser( + id='hij', + name='Inactive User', + is_active=False, + ).add_to_hass(hass) + + refresh_token = await hass.auth.async_create_refresh_token( + owner, CLIENT_ID) + access_token = hass.auth.async_create_access_token(refresh_token) + + client = await hass_ws_client(hass, access_token) + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_LIST, + }) + + result = await client.receive_json() + assert result['success'], result + data = result['result'] + assert len(data) == 3 + assert data[0] == { + 'id': owner.id, + 'name': 'Test Owner', + 'is_owner': True, + 'is_active': True, + 'system_generated': False, + 'credentials': [{'type': 'homeassistant'}] + } + assert data[1] == { + 'id': system.id, + 'name': 'Test Hass.io', + 'is_owner': False, + 'is_active': True, + 'system_generated': True, + 'credentials': [], + } + assert data[2] == { + 'id': inactive.id, + 'name': 'Inactive User', + 'is_owner': False, + 'is_active': False, + 'system_generated': False, + 'credentials': [], + } + + +async def test_delete_requires_owner(hass, hass_ws_client, hass_access_token): + """Test delete command requires an owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': 'abcd', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_delete_unable_self_account(hass, hass_ws_client, + hass_access_token): + """Test we cannot delete our own account.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': hass_access_token.refresh_token.user.id, + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_delete_unknown_user(hass, hass_ws_client, hass_access_token): + """Test we cannot delete an unknown user.""" + client = await hass_ws_client(hass, hass_access_token) + hass_access_token.refresh_token.user.is_owner = True + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': 'abcd', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'not_found' + + +async def test_delete(hass, hass_ws_client, hass_access_token): + """Test delete command works.""" + client = await hass_ws_client(hass, hass_access_token) + hass_access_token.refresh_token.user.is_owner = True + test_user = MockUser( + id='efg', + ).add_to_hass(hass) + + assert len(await hass.auth.async_get_users()) == 2 + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': test_user.id, + }) + + result = await client.receive_json() + assert result['success'], result + assert len(await hass.auth.async_get_users()) == 1 + + +async def test_create(hass, hass_ws_client, hass_access_token): + """Test create command works.""" + client = await hass_ws_client(hass, hass_access_token) + hass_access_token.refresh_token.user.is_owner = True + + assert len(await hass.auth.async_get_users()) == 1 + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_CREATE, + 'name': 'Paulus', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(await hass.auth.async_get_users()) == 2 + data_user = result['result']['user'] + user = await hass.auth.async_get_user(data_user['id']) + assert user is not None + assert user.name == data_user['name'] + assert user.is_active + assert not user.is_owner + assert not user.system_generated + + +async def test_create_requires_owner(hass, hass_ws_client, hass_access_token): + """Test create command requires an owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_CREATE, + 'name': 'YO', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py new file mode 100644 index 00000000000000..fa4ab612bb12c5 --- /dev/null +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -0,0 +1,229 @@ +"""Test config entries API.""" +import pytest + +from homeassistant.auth.providers import homeassistant as prov_ha +from homeassistant.components.config import ( + auth_provider_homeassistant as auth_ha) + +from tests.common import MockUser, register_auth_provider + + +@pytest.fixture(autouse=True) +def setup_config(hass, aiohttp_client): + """Fixture that sets up the auth provider homeassistant module.""" + hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant' + })) + hass.loop.run_until_complete(auth_ha.async_setup(hass)) + + +async def test_create_auth_system_generated_user(hass, hass_access_token, + hass_ws_client): + """Test we can't add auth to system generated users.""" + system_user = MockUser(system_generated=True).add_to_hass(hass) + client = await hass_ws_client(hass, hass_access_token) + hass_access_token.refresh_token.user.is_owner = True + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': system_user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + + assert not result['success'], result + assert result['error']['code'] == 'system_generated' + + +async def test_create_auth_user_already_credentials(): + """Test we can't create auth for user with pre-existing credentials.""" + # assert False + + +async def test_create_auth_unknown_user(hass_ws_client, hass, + hass_access_token): + """Test create pointing at unknown user.""" + client = await hass_ws_client(hass, hass_access_token) + hass_access_token.refresh_token.user.is_owner = True + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': 'test-id', + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + + assert not result['success'], result + assert result['error']['code'] == 'not_found' + + +async def test_create_auth_requires_owner(hass, hass_ws_client, + hass_access_token): + """Test create requires owner to call API.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': 'test-id', + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_create_auth(hass, hass_ws_client, hass_access_token, + hass_storage): + """Test create auth command works.""" + client = await hass_ws_client(hass, hass_access_token) + user = MockUser().add_to_hass(hass) + hass_access_token.refresh_token.user.is_owner = True + + assert len(user.credentials) == 0 + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(user.credentials) == 1 + creds = user.credentials[0] + assert creds.auth_provider_type == 'homeassistant' + assert creds.auth_provider_id is None + assert creds.data == { + 'username': 'test-user' + } + assert prov_ha.STORAGE_KEY in hass_storage + entry = hass_storage[prov_ha.STORAGE_KEY]['data']['users'][0] + assert entry['username'] == 'test-user' + + +async def test_create_auth_duplicate_username(hass, hass_ws_client, + hass_access_token, hass_storage): + """Test we can't create auth with a duplicate username.""" + client = await hass_ws_client(hass, hass_access_token) + user = MockUser().add_to_hass(hass) + hass_access_token.refresh_token.user.is_owner = True + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'username_exists' + + +async def test_delete_removes_just_auth(hass_ws_client, hass, hass_storage, + hass_access_token): + """Test deleting an auth without being connected to a user.""" + client = await hass_ws_client(hass, hass_access_token) + hass_access_token.refresh_token.user.is_owner = True + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(hass_storage[prov_ha.STORAGE_KEY]['data']['users']) == 0 + + +async def test_delete_removes_credential(hass, hass_ws_client, + hass_access_token, hass_storage): + """Test deleting auth that is connected to a user.""" + client = await hass_ws_client(hass, hass_access_token) + hass_access_token.refresh_token.user.is_owner = True + + user = MockUser().add_to_hass(hass) + user.credentials.append( + await hass.auth.auth_providers[0].async_get_or_create_credentials({ + 'username': 'test-user'})) + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(hass_storage[prov_ha.STORAGE_KEY]['data']['users']) == 0 + + +async def test_delete_requires_owner(hass, hass_ws_client, hass_access_token): + """Test delete requires owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_delete_unknown_auth(hass, hass_ws_client, hass_access_token): + """Test trying to delete an unknown auth username.""" + client = await hass_ws_client(hass, hass_access_token) + hass_access_token.refresh_token.user.is_owner = True + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'auth_not_found' diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 843866cbfbd6dc..5f6a17a4101cb1 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -2,6 +2,7 @@ import pytest from homeassistant.setup import async_setup_component +from homeassistant.components import websocket_api from tests.common import MockUser, CLIENT_ID @@ -9,13 +10,27 @@ @pytest.fixture def hass_ws_client(aiohttp_client): """Websocket client fixture connected to websocket server.""" - async def create_client(hass): + async def create_client(hass, access_token=None): """Create a websocket client.""" wapi = hass.components.websocket_api assert await async_setup_component(hass, 'websocket_api') client = await aiohttp_client(hass.http.app) websocket = await client.ws_connect(wapi.URL) + auth_resp = await websocket.receive_json() + + if auth_resp['type'] == wapi.TYPE_AUTH_OK: + assert access_token is None, \ + 'Access token given but no auth required' + return websocket + + assert access_token is not None, 'Access token required for fixture' + + await websocket.send_json({ + 'type': websocket_api.TYPE_AUTH, + 'access_token': access_token.token + }) + auth_ok = await websocket.receive_json() assert auth_ok['type'] == wapi.TYPE_AUTH_OK diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 19785958422836..31cba79a6c8758 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,13 +1,12 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access from ipaddress import ip_network -from unittest.mock import patch, Mock +from unittest.mock import patch import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -from homeassistant.auth.models import AccessToken, RefreshToken from homeassistant.components.http.auth import setup_auth from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip @@ -16,8 +15,6 @@ from . import mock_real_ip -ACCESS_TOKEN = 'tk.1234' - API_PASSWORD = 'test1234' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases @@ -39,33 +36,21 @@ async def mock_handler(request): return web.Response(status=200) -def mock_async_get_access_token(token): - """Return if token is valid.""" - if token == ACCESS_TOKEN: - return Mock(spec=AccessToken, - token=ACCESS_TOKEN, - refresh_token=Mock(spec=RefreshToken)) - else: - return None - - @pytest.fixture -def app(): +def app(hass): """Fixture to setup a web.Application.""" app = web.Application() - mock_auth = Mock(async_get_access_token=mock_async_get_access_token) - app['hass'] = Mock(auth=mock_auth) + app['hass'] = hass app.router.add_get('/', mock_handler) setup_real_ip(app, False, []) return app @pytest.fixture -def app2(): +def app2(hass): """Fixture to setup a web.Application without real_ip middleware.""" app = web.Application() - mock_auth = Mock(async_get_access_token=mock_async_get_access_token) - app['hass'] = Mock(auth=mock_auth) + app['hass'] = hass app.router.add_get('/', mock_handler) return app @@ -171,33 +156,35 @@ async def test_access_with_trusted_ip(app2, aiohttp_client): async def test_auth_active_access_with_access_token_in_header( - app, aiohttp_client): + app, aiohttp_client, hass_access_token): """Test access with access token in header.""" + token = hass_access_token.token setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) req = await client.get( - '/', headers={'Authorization': 'Bearer {}'.format(ACCESS_TOKEN)}) + '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 200 req = await client.get( - '/', headers={'AUTHORIZATION': 'Bearer {}'.format(ACCESS_TOKEN)}) + '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)}) assert req.status == 200 req = await client.get( - '/', headers={'authorization': 'Bearer {}'.format(ACCESS_TOKEN)}) + '/', headers={'authorization': 'Bearer {}'.format(token)}) assert req.status == 200 req = await client.get( - '/', headers={'Authorization': ACCESS_TOKEN}) + '/', headers={'Authorization': token}) assert req.status == 401 req = await client.get( - '/', headers={'Authorization': 'BEARER {}'.format(ACCESS_TOKEN)}) + '/', headers={'Authorization': 'BEARER {}'.format(token)}) assert req.status == 401 + hass_access_token.refresh_token.user.is_active = False req = await client.get( - '/', headers={'Authorization': 'Bearer wrong-pass'}) + '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 401 diff --git a/tests/conftest.py b/tests/conftest.py index 0a350b62fc1cb9..28c47948666569 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ import uvloop asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG) logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index cd0524eb032660..1320be299b83cb 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -6,21 +6,26 @@ from homeassistant.scripts import auth as script_auth from homeassistant.auth.providers import homeassistant as hass_auth +from tests.common import register_auth_provider + @pytest.fixture -def data(hass): - """Create a loaded data class.""" - data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) - return data +def provider(hass): + """Home Assistant auth provider.""" + provider = hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant', + })) + hass.loop.run_until_complete(provider.async_initialize()) + return provider -async def test_list_user(data, capsys): +async def test_list_user(hass, provider, capsys): """Test we can list users.""" - data.add_user('test-user', 'test-pass') - data.add_user('second-user', 'second-pass') + data = provider.data + data.add_auth('test-user', 'test-pass') + data.add_auth('second-user', 'second-pass') - await script_auth.list_users(data, None) + await script_auth.list_users(hass, provider, None) captured = capsys.readouterr() @@ -33,10 +38,11 @@ async def test_list_user(data, capsys): ]) -async def test_add_user(data, capsys, hass_storage): +async def test_add_user(hass, provider, capsys, hass_storage): """Test we can add a user.""" + data = provider.data await script_auth.add_user( - data, Mock(username='paulus', password='test-pass')) + hass, provider, Mock(username='paulus', password='test-pass')) assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 @@ -47,32 +53,34 @@ async def test_add_user(data, capsys, hass_storage): data.validate_login('paulus', 'test-pass') -async def test_validate_login(data, capsys): +async def test_validate_login(hass, provider, capsys): """Test we can validate a user login.""" - data.add_user('test-user', 'test-pass') + data = provider.data + data.add_auth('test-user', 'test-pass') await script_auth.validate_login( - data, Mock(username='test-user', password='test-pass')) + hass, provider, Mock(username='test-user', password='test-pass')) captured = capsys.readouterr() assert captured.out == 'Auth valid\n' await script_auth.validate_login( - data, Mock(username='test-user', password='invalid-pass')) + hass, provider, Mock(username='test-user', password='invalid-pass')) captured = capsys.readouterr() assert captured.out == 'Auth invalid\n' await script_auth.validate_login( - data, Mock(username='invalid-user', password='test-pass')) + hass, provider, Mock(username='invalid-user', password='test-pass')) captured = capsys.readouterr() assert captured.out == 'Auth invalid\n' -async def test_change_password(data, capsys, hass_storage): +async def test_change_password(hass, provider, capsys, hass_storage): """Test we can change a password.""" - data.add_user('test-user', 'test-pass') + data = provider.data + data.add_auth('test-user', 'test-pass') await script_auth.change_password( - data, Mock(username='test-user', new_password='new-pass')) + hass, provider, Mock(username='test-user', new_password='new-pass')) assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 captured = capsys.readouterr() @@ -82,12 +90,14 @@ async def test_change_password(data, capsys, hass_storage): data.validate_login('test-user', 'test-pass') -async def test_change_password_invalid_user(data, capsys, hass_storage): +async def test_change_password_invalid_user(hass, provider, capsys, + hass_storage): """Test changing password of non-existing user.""" - data.add_user('test-user', 'test-pass') + data = provider.data + data.add_auth('test-user', 'test-pass') await script_auth.change_password( - data, Mock(username='invalid-user', new_password='new-pass')) + hass, provider, Mock(username='invalid-user', new_password='new-pass')) assert hass_auth.STORAGE_KEY not in hass_storage captured = capsys.readouterr() @@ -101,11 +111,11 @@ def test_parsing_args(loop): """Test we parse args correctly.""" called = False - async def mock_func(data, args2): + async def mock_func(hass, provider, args2): """Mock function to be called.""" nonlocal called called = True - assert data.hass.config.config_dir == '/somewhere/config' + assert provider.hass.config.config_dir == '/somewhere/config' assert args2 is args args = Mock(config='/somewhere/config', func=mock_func) From ae581694ac5f3e0b99f8ffe6b7df6174cd209b63 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jul 2018 15:33:33 +0200 Subject: [PATCH 106/147] Bump frontend to 20180713.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 0fa9f90805d0d9..76182dea6f9e37 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180710.0'] +REQUIREMENTS = ['home-assistant-frontend==20180713.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 0690539bdee039..7ac82d93a56a6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180710.0 +home-assistant-frontend==20180713.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4a8bd03b3ff38..5967bd7c4dcceb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180710.0 +home-assistant-frontend==20180713.0 # homeassistant.components.homematicip_cloud homematicip==0.9.6 From e60f9ca3928aef9f546a1ecd70fa525a306a1f5c Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 13 Jul 2018 20:14:45 +0300 Subject: [PATCH 107/147] More typing (#15449) ## Description: More typing improvements. Switch to using `mypy.ini` for flexibility Add `warn_return_any` check except in `homeassistant.util.yaml` that does typing hacks. Fix some type annotations as resulting from this check and ignore others were fixing is hard. ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** --- homeassistant/config.py | 13 ++++++------- homeassistant/core.py | 11 ++++++----- homeassistant/loader.py | 2 +- homeassistant/remote.py | 2 +- homeassistant/setup.py | 6 +++--- homeassistant/util/json.py | 2 +- mypy.ini | 11 +++++++++++ tox.ini | 2 +- 8 files changed, 30 insertions(+), 19 deletions(-) create mode 100644 mypy.ini diff --git a/homeassistant/config.py b/homeassistant/config.py index 48632ccab83723..2afa943ee50688 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -171,7 +171,8 @@ def get_default_config_dir() -> str: return os.path.join(data_dir, CONFIG_DIR_NAME) # type: ignore -def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: +def ensure_config_exists(config_dir: str, detect_location: bool = True)\ + -> Optional[str]: """Ensure a configuration file exists in given configuration directory. Creating a default one if needed. @@ -187,7 +188,8 @@ def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: return config_path -def create_default_config(config_dir, detect_location=True): +def create_default_config(config_dir: str, detect_location=True)\ + -> Optional[str]: """Create a default configuration file in given configuration directory. Return path to new config file if success, None if failed. @@ -286,11 +288,8 @@ def _load_hass_yaml_config(): return conf -def find_config_file(config_dir): - """Look in given directory for supported configuration files. - - Async friendly. - """ +def find_config_file(config_dir: str) -> Optional[str]: + """Look in given directory for supported configuration files.""" config_path = os.path.join(config_dir, YAML_CONFIG_FILE) return config_path if os.path.isfile(config_path) else None diff --git a/homeassistant/core.py b/homeassistant/core.py index c7aa04910bd758..8b534bf173154f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -106,7 +106,7 @@ class CoreState(enum.Enum): def __str__(self) -> str: """Return the event.""" - return self.value + return self.value # type: ignore class HomeAssistant(object): @@ -137,7 +137,7 @@ def __init__(self, loop=None): # This is a dictionary that any component can store any data on. self.data = {} self.state = CoreState.not_running - self.exit_code = None + self.exit_code = 0 # type: int self.config_entries = None @property @@ -239,7 +239,7 @@ def async_create_task(self, target: Coroutine) -> asyncio.tasks.Task: target: target to call. """ - task = self.loop.create_task(target) + task = self.loop.create_task(target) # type: asyncio.tasks.Task if self._track_task: self._pending_tasks.append(task) @@ -252,7 +252,8 @@ def async_add_executor_job( target: Callable[..., Any], *args: Any) -> asyncio.Future: """Add an executor job from within the event loop.""" - task = self.loop.run_in_executor(None, target, *args) + task = self.loop.run_in_executor( + None, target, *args) # type: asyncio.Future # If a task is scheduled if self._track_task: @@ -307,7 +308,7 @@ def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" fire_coroutine_threadsafe(self.async_stop(), self.loop) - async def async_stop(self, exit_code=0) -> None: + async def async_stop(self, exit_code: int = 0) -> None: """Stop Home Assistant and shuts down all threads. This method is a coroutine. diff --git a/homeassistant/loader.py b/homeassistant/loader.py index b22271d6eb532c..52e6b1e770353b 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -67,7 +67,7 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: Async friendly. """ try: - return hass.data[DATA_KEY][comp_or_platform] + return hass.data[DATA_KEY][comp_or_platform] # type: ignore except KeyError: pass diff --git a/homeassistant/remote.py b/homeassistant/remote.py index b3e5f417618497..ae932b7d9554a6 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -38,7 +38,7 @@ class APIStatus(enum.Enum): def __str__(self) -> str: """Return the state.""" - return self.value + return self.value # type: ignore class API(object): diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 5398cfde96311e..478320dca278a7 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -26,7 +26,7 @@ def setup_component(hass: core.HomeAssistant, domain: str, config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies.""" - return run_coroutine_threadsafe( + return run_coroutine_threadsafe( # type: ignore async_setup_component(hass, domain, config), loop=hass.loop).result() @@ -42,7 +42,7 @@ async def async_setup_component(hass: core.HomeAssistant, domain: str, setup_tasks = hass.data.get(DATA_SETUP) if setup_tasks is not None and domain in setup_tasks: - return await setup_tasks[domain] + return await setup_tasks[domain] # type: ignore if config is None: config = {} @@ -53,7 +53,7 @@ async def async_setup_component(hass: core.HomeAssistant, domain: str, task = setup_tasks[domain] = hass.async_create_task( _async_setup_component(hass, domain, config)) - return await task + return await task # type: ignore async def _async_process_dependencies(hass, config, name, dependencies): diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 74feb779dcd4cb..1029e58c1186c7 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -25,7 +25,7 @@ def load_json(filename: str, default: Union[List, Dict, None] = None) \ """ try: with open(filename, encoding='utf-8') as fdesc: - return json.loads(fdesc.read()) + return json.loads(fdesc.read()) # type: ignore except FileNotFoundError: # This is not a fatal error _LOGGER.debug('JSON file not found: %s', filename) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000000000..3970ea72d471a7 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,11 @@ +[mypy] +warn_redundant_casts = true +warn_unused_configs = true +ignore_missing_imports = true +follow_imports = silent +warn_unused_ignores = true +warn_return_any = true + +[mypy-homeassistant.util.yaml] +warn_return_any = false + diff --git a/tox.ini b/tox.ini index 6e22f2a5e95757..fb36ac6511a8e4 100644 --- a/tox.ini +++ b/tox.ini @@ -42,4 +42,4 @@ whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - /bin/bash -c 'mypy --ignore-missing-imports --follow-imports=silent --strict-optional --warn-unused-ignores homeassistant/*.py homeassistant/util/' + /bin/bash -c 'mypy homeassistant/*.py homeassistant/util/' From 79955a57853cfd9d499489f0f30e62fbef30dfdf Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 13 Jul 2018 20:01:57 +0200 Subject: [PATCH 108/147] Catch the ValueError if the bulb was in the wrong mode (#15434) --- homeassistant/components/light/mystrom.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/mystrom.py b/homeassistant/components/light/mystrom.py index 9abd96664f26ad..5d4cdcc17d4bdd 100644 --- a/homeassistant/components/light/mystrom.py +++ b/homeassistant/components/light/mystrom.py @@ -155,7 +155,11 @@ def update(self): self._state = self._bulb.get_status() colors = self._bulb.get_color()['color'] - color_h, color_s, color_v = colors.split(';') + try: + color_h, color_s, color_v = colors.split(';') + except ValueError: + color_s, color_v = colors.split(';') + color_h = 0 self._color_h = int(color_h) self._color_s = int(color_s) From 1007bb83aa46d1abf29594c3bceeb2f585fc787a Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 13 Jul 2018 20:02:13 +0200 Subject: [PATCH 109/147] Upgrade keyring to 13.2.1 (#15453) --- homeassistant/scripts/keyring.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index ecb31bdef86163..54e7eb01ae1a9b 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==13.2.0', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==13.2.1', 'keyrings.alt==3.1'] def run(args): diff --git a/requirements_all.txt b/requirements_all.txt index 7ac82d93a56a6e..867f9d502b9a22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -471,7 +471,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==13.2.0 +keyring==13.2.1 # homeassistant.scripts.keyring keyrings.alt==3.1 From 538236de8f5cb3681e0831d56e9de4d7f650e37d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jul 2018 23:02:23 +0200 Subject: [PATCH 110/147] Fix formatting pylint comments in test (#15450) --- tests/components/binary_sensor/test_trend.py | 6 ++---- .../components/device_tracker/test_asuswrt.py | 15 +++++---------- tests/components/device_tracker/test_mqtt.py | 3 +-- .../device_tracker/test_mqtt_json.py | 3 +-- .../device_tracker/test_unifi_direct.py | 3 +-- tests/components/light/test_mqtt.py | 12 ++++-------- tests/components/light/test_mqtt_json.py | 18 ++++++------------ tests/components/light/test_mqtt_template.py | 18 ++++++------------ .../test_device_sun_light_trigger.py | 6 ++---- tests/components/test_history.py | 3 +-- tests/components/test_logbook.py | 3 +-- tests/scripts/test_check_config.py | 6 ++---- 12 files changed, 32 insertions(+), 64 deletions(-) diff --git a/tests/components/binary_sensor/test_trend.py b/tests/components/binary_sensor/test_trend.py index c1083cc1857b6e..b77f9060b40696 100644 --- a/tests/components/binary_sensor/test_trend.py +++ b/tests/components/binary_sensor/test_trend.py @@ -273,8 +273,7 @@ def test_missing_attribute(self): state = self.hass.states.get('binary_sensor.test_trend_sensor') assert state.state == 'off' - def test_invalid_name_does_not_create(self): \ - # pylint: disable=invalid-name + def test_invalid_name_does_not_create(self): """Test invalid name.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { @@ -290,8 +289,7 @@ def test_invalid_name_does_not_create(self): \ }) assert self.hass.states.all() == [] - def test_invalid_sensor_does_not_create(self): \ - # pylint: disable=invalid-name + def test_invalid_sensor_does_not_create(self): """Test invalid sensor.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 0cbece6d1b0c03..956b407eeaa25f 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -168,8 +168,7 @@ def test_scan_devices(self): scanner.last_results = WAKE_DEVICES self.assertEqual(list(WAKE_DEVICES), scanner.scan_devices()) - def test_password_or_pub_key_required(self): \ - # pylint: disable=invalid-name + def test_password_or_pub_key_required(self): """Test creating an AsusWRT scanner without a pass or pubkey.""" with assert_setup_component(0, DOMAIN): assert setup_component( @@ -183,8 +182,7 @@ def test_password_or_pub_key_required(self): \ @mock.patch( 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', return_value=mock.MagicMock()) - def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): \ - # pylint: disable=invalid-name + def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): """Test creating an AsusWRT scanner with a password and no pubkey.""" conf_dict = { DOMAIN: { @@ -213,8 +211,7 @@ def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): \ @mock.patch( 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', return_value=mock.MagicMock()) - def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): \ - # pylint: disable=invalid-name + def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): """Test creating an AsusWRT scanner with a pubkey and no password.""" conf_dict = { device_tracker.DOMAIN: { @@ -292,8 +289,7 @@ def test_ssh_login_with_password(self): password='fake_pass', port=22) ) - def test_ssh_login_without_password_or_pubkey(self): \ - # pylint: disable=invalid-name + def test_ssh_login_without_password_or_pubkey(self): """Test that login is not called without password or pub_key.""" ssh = mock.MagicMock() ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) @@ -363,8 +359,7 @@ def test_telnet_login_with_password(self): mock.call(b'#') ) - def test_telnet_login_without_password(self): \ - # pylint: disable=invalid-name + def test_telnet_login_without_password(self): """Test that login is not called without password or pub_key.""" telnet = mock.MagicMock() telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index 78750e91f833b7..de7865517a8366 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -31,8 +31,7 @@ def tearDown(self): # pylint: disable=invalid-name except FileNotFoundError: pass - def test_ensure_device_tracker_platform_validation(self): \ - # pylint: disable=invalid-name + def test_ensure_device_tracker_platform_validation(self): """Test if platform validation was done.""" @asyncio.coroutine def mock_setup_scanner(hass, config, see, discovery_info=None): diff --git a/tests/components/device_tracker/test_mqtt_json.py b/tests/components/device_tracker/test_mqtt_json.py index 43f4fc3bbf3484..8ab6346f19be6b 100644 --- a/tests/components/device_tracker/test_mqtt_json.py +++ b/tests/components/device_tracker/test_mqtt_json.py @@ -41,8 +41,7 @@ def tearDown(self): # pylint: disable=invalid-name except FileNotFoundError: pass - def test_ensure_device_tracker_platform_validation(self): \ - # pylint: disable=invalid-name + def test_ensure_device_tracker_platform_validation(self): """Test if platform validation was done.""" @asyncio.coroutine def mock_setup_scanner(hass, config, see, discovery_info=None): diff --git a/tests/components/device_tracker/test_unifi_direct.py b/tests/components/device_tracker/test_unifi_direct.py index ccfa59404a15a6..d1ede7211426c7 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -45,8 +45,7 @@ def teardown_method(self, _): @mock.patch(scanner_path, return_value=mock.MagicMock()) - def test_get_scanner(self, unifi_mock): \ - # pylint: disable=invalid-name + def test_get_scanner(self, unifi_mock): """Test creating an Unifi direct scanner with a password.""" conf_dict = { DOMAIN: { diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 49bcd8a73ecc0c..7d6dd65e90a58e 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -177,8 +177,7 @@ def test_fail_setup_if_no_command_topic(self): }) self.assertIsNone(self.hass.states.get('light.test')) - def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): \ - # pylint: disable=invalid-name + def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): """Test if there is no color and brightness if no topic.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -209,8 +208,7 @@ def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): \ self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) - def test_controlling_state_via_topic(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic(self): """Test the controlling of the state via topic.""" config = {light.DOMAIN: { 'platform': 'mqtt', @@ -410,8 +408,7 @@ def test_white_value_controlling_scale(self): self.assertEqual(255, light_state.attributes['white_value']) - def test_controlling_state_via_topic_with_templates(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic_with_templates(self): """Test the setting og the state with a template.""" config = {light.DOMAIN: { 'platform': 'mqtt', @@ -466,8 +463,7 @@ def test_controlling_state_via_topic_with_templates(self): \ self.assertEqual(75, state.attributes.get('white_value')) self.assertEqual((0.14, 0.131), state.attributes.get('xy_color')) - def test_sending_mqtt_commands_and_optimistic(self): \ - # pylint: disable=invalid-name + def test_sending_mqtt_commands_and_optimistic(self): """Test the sending of command in optimistic mode.""" config = {light.DOMAIN: { 'platform': 'mqtt', diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index af560bff9c3224..f16685b3575827 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -115,8 +115,7 @@ def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() - def test_fail_setup_if_no_command_topic(self): \ - # pylint: disable=invalid-name + def test_fail_setup_if_no_command_topic(self): """Test if setup fails with no command topic.""" with assert_setup_component(0, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -127,8 +126,7 @@ def test_fail_setup_if_no_command_topic(self): \ }) self.assertIsNone(self.hass.states.get('light.test')) - def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ - # pylint: disable=invalid-name + def test_no_color_brightness_color_temp_white_val_if_no_topics(self): """Test for no RGB, brightness, color temp, effect, white val or XY.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -163,8 +161,7 @@ def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ self.assertIsNone(state.attributes.get('xy_color')) self.assertIsNone(state.attributes.get('hs_color')) - def test_controlling_state_via_topic(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic(self): """Test the controlling of the state via topic.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -283,8 +280,7 @@ def test_controlling_state_via_topic(self): \ light_state = self.hass.states.get('light.test') self.assertEqual(155, light_state.attributes.get('white_value')) - def test_sending_mqtt_commands_and_optimistic(self): \ - # pylint: disable=invalid-name + def test_sending_mqtt_commands_and_optimistic(self): """Test the sending of command in optimistic mode.""" fake_state = ha.State('light.test', 'on', {'brightness': 95, 'hs_color': [100, 100], @@ -413,8 +409,7 @@ def test_sending_hs_color(self): 's': 50.0, }, message_json["color"]) - def test_flash_short_and_long(self): \ - # pylint: disable=invalid-name + def test_flash_short_and_long(self): """Test for flash length being sent when included.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -547,8 +542,7 @@ def test_brightness_scale(self): self.assertEqual(STATE_ON, state.state) self.assertEqual(255, state.attributes.get('brightness')) - def test_invalid_color_brightness_and_white_values(self): \ - # pylint: disable=invalid-name + def test_invalid_color_brightness_and_white_values(self): """Test that invalid color/brightness/white values are ignored.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 1440a73f98eb83..e1c3da50e7e5ac 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -51,8 +51,7 @@ def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() - def test_setup_fails(self): \ - # pylint: disable=invalid-name + def test_setup_fails(self): """Test that setup fails with missing required configuration items.""" with assert_setup_component(0, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -63,8 +62,7 @@ def test_setup_fails(self): \ }) self.assertIsNone(self.hass.states.get('light.test')) - def test_state_change_via_topic(self): \ - # pylint: disable=invalid-name + def test_state_change_via_topic(self): """Test state change via topic.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -103,8 +101,7 @@ def test_state_change_via_topic(self): \ self.assertIsNone(state.attributes.get('color_temp')) self.assertIsNone(state.attributes.get('white_value')) - def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ - # pylint: disable=invalid-name + def test_state_brightness_color_effect_temp_white_change_via_topic(self): """Test state, bri, color, effect, color temp, white val change.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -206,8 +203,7 @@ def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ light_state = self.hass.states.get('light.test') self.assertEqual('rainbow', light_state.attributes.get('effect')) - def test_optimistic(self): \ - # pylint: disable=invalid-name + def test_optimistic(self): """Test optimistic mode.""" fake_state = ha.State('light.test', 'on', {'brightness': 95, 'hs_color': [100, 100], @@ -289,8 +285,7 @@ def test_optimistic(self): \ self.assertEqual(200, state.attributes['color_temp']) self.assertEqual(139, state.attributes['white_value']) - def test_flash(self): \ - # pylint: disable=invalid-name + def test_flash(self): """Test flash.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -353,8 +348,7 @@ def test_transition(self): self.mock_publish.async_publish.assert_called_once_with( 'test_light_rgb/set', 'off,4', 0, False) - def test_invalid_values(self): \ - # pylint: disable=invalid-name + def test_invalid_values(self): """Test that invalid values are ignored.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index a8b8a201217d6c..774185c51c1cdc 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -79,8 +79,7 @@ def test_lights_on_when_sun_sets(self): self.assertTrue(light.is_on(self.hass)) - def test_lights_turn_off_when_everyone_leaves(self): \ - # pylint: disable=invalid-name + def test_lights_turn_off_when_everyone_leaves(self): """Test lights turn off when everyone leaves the house.""" light.turn_on(self.hass) @@ -97,8 +96,7 @@ def test_lights_turn_off_when_everyone_leaves(self): \ self.assertFalse(light.is_on(self.hass)) - def test_lights_turn_on_when_coming_home_after_sun_set(self): \ - # pylint: disable=invalid-name + def test_lights_turn_on_when_coming_home_after_sun_set(self): """Test lights turn on when coming home after sun set.""" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) with patch('homeassistant.util.dt.utcnow', return_value=test_time): diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 5d909492380c12..70f7152e07f7bd 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -428,8 +428,7 @@ def test_get_significant_states_include_exclude(self): history.CONF_ENTITIES: ['media_player.test']}}}) self.check_significant_states(zero, four, states, config) - def check_significant_states(self, zero, four, states, config): \ - # pylint: disable=no-self-use + def check_significant_states(self, zero, four, states, config): """Check if significant states are retrieved.""" filters = history.Filters() exclude = config[history.DOMAIN].get(history.CONF_EXCLUDE) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 6c71a263afa39d..a3a5273ed4e1d1 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -542,8 +542,7 @@ def assert_entry(self, entry, when=None, name=None, message=None, def create_state_changed_event(self, event_time_fired, entity_id, state, attributes=None, last_changed=None, - last_updated=None): \ - # pylint: disable=no-self-use + last_updated=None): """Create state changed event.""" # Logbook only cares about state change events that # contain an old state but will not actually act on it. diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 33154090286d76..540f8d91da914e 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -168,8 +168,7 @@ def test_secrets(self, isfile_patch): '.../configuration.yaml', '.../secrets.yaml'] @patch('os.path.isfile', return_value=True) - def test_package_invalid(self, isfile_patch): \ - # pylint: disable=no-self-use,invalid-name + def test_package_invalid(self, isfile_patch): """Test a valid platform setup.""" files = { YAML_CONFIG_FILE: BASE_CONFIG + ( @@ -190,8 +189,7 @@ def test_package_invalid(self, isfile_patch): \ assert res['secrets'] == {} assert len(res['yaml_files']) == 1 - def test_bootstrap_error(self): \ - # pylint: disable=no-self-use,invalid-name + def test_bootstrap_error(self): """Test a valid platform setup.""" files = { YAML_CONFIG_FILE: BASE_CONFIG + 'automation: !include no.yaml', From ce5b4cd51e88898acc93452e9cf8650f515cc231 Mon Sep 17 00:00:00 2001 From: Mattias Welponer Date: Fri, 13 Jul 2018 23:25:11 +0200 Subject: [PATCH 111/147] Add HomematicIP Cloud dimmer light device (#15456) * Add dimmable light device * Add imports * Fix float and int conversion --- .../components/light/homematicip_cloud.py | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py index 5c513113f90fb4..9851248f7cc7d1 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -7,7 +7,8 @@ import logging -from homeassistant.components.light import Light +from homeassistant.components.light import ( + Light, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS) from homeassistant.components.homematicip_cloud import ( HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID) @@ -30,13 +31,15 @@ async def async_setup_platform(hass, config, async_add_devices, async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the HomematicIP lights from a config entry.""" from homematicip.aio.device import ( - AsyncBrandSwitchMeasuring) + AsyncBrandSwitchMeasuring, AsyncPluggableDimmer) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): devices.append(HomematicipLightMeasuring(home, device)) + elif isinstance(device, AsyncPluggableDimmer): + devices.append(HomematicipDimmer(home, device)) if devices: async_add_devices(devices) @@ -79,3 +82,38 @@ def device_state_attributes(self): ATTR_ENERGIE_COUNTER: round(self._device.energyCounter, 2) }) return attr + + +class HomematicipDimmer(HomematicipGenericDevice, Light): + """MomematicIP dimmer light device.""" + + def __init__(self, home, device): + """Initialize the dimmer light device.""" + super().__init__(home, device) + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.dimLevel != 0 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._device.dimLevel*255) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + await self._device.set_dim_level( + kwargs[ATTR_BRIGHTNESS]/255.0) + else: + await self._device.set_dim_level(1) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._device.set_dim_level(0) From 6e22a0e4d91b3b8d5f07263f61029357a7c33b46 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Fri, 13 Jul 2018 18:54:15 -0400 Subject: [PATCH 112/147] Fix ZWave RGBW lights not producing color without explicit white_value (#15412) * Fix ZWave RGBW lights not producing color without explicit white_value (#13930) * simplify conditional in previous commit (#13930) * ZwaveColorLight - only zero _white if white_value not specified in call (#13930) --- homeassistant/components/light/zwave.py | 4 +++- tests/components/light/test_zwave.py | 26 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 3bfa167f8eca20..f468e8c25ef1ee 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -324,9 +324,11 @@ def turn_on(self, **kwargs): else: self._ct = TEMP_COLD_HASS rgbw = '#00000000ff' - elif ATTR_HS_COLOR in kwargs: self._hs = kwargs[ATTR_HS_COLOR] + if ATTR_WHITE_VALUE not in kwargs: + # white LED must be off in order for color to work + self._white = 0 if ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs: rgbw = '#' diff --git a/tests/components/light/test_zwave.py b/tests/components/light/test_zwave.py index 4966b161360224..62bcf834b98f6a 100644 --- a/tests/components/light/test_zwave.py +++ b/tests/components/light/test_zwave.py @@ -255,6 +255,32 @@ def test_set_white_value(mock_openzwave): assert color.data == '#ffffffc800' +def test_disable_white_if_set_color(mock_openzwave): + """ + Test that _white is set to 0 if turn_on with ATTR_HS_COLOR. + + See Issue #13930 - many RGBW ZWave bulbs will only activate the RGB LED to + produce color if _white is set to zero. + """ + node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR]) + value = MockValue(data=0, node=node) + color = MockValue(data='#0000000000', node=node) + # Supports RGB only + color_channels = MockValue(data=0x1c, node=node) + values = MockLightValues(primary=value, color=color, + color_channels=color_channels) + device = zwave.get_device(node=node, values=values, node_config={}) + device._white = 234 + + assert color.data == '#0000000000' + assert device.white_value == 234 + + device.turn_on(**{ATTR_HS_COLOR: (30, 50)}) + + assert device.white_value == 0 + assert color.data == '#ffbf7f0000' + + def test_zw098_set_color_temp(mock_openzwave): """Test setting zwave light color.""" node = MockNode(manufacturer_id='0086', product_id='0062', From 3b5775573b68c3d1426361410833d408f0063221 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sat, 14 Jul 2018 02:31:51 +0200 Subject: [PATCH 113/147] Add IPPassageSensor (HmIP-SPDR) (#15458) --- homeassistant/components/homematic/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 12de686d232d3e..6754db05f770f4 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -71,7 +71,7 @@ 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', - 'IPWeatherSensor', 'RotaryHandleSensorIP'], + 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -80,7 +80,7 @@ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', - 'WiredSensor', 'PresenceIP', 'IPWeatherSensor'], + 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } @@ -114,7 +114,7 @@ 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], 'OPERATING_VOLTAGE': ['voltage', {}], - 'WORKING': ['working', {0: 'No', 1: 'Yes'}], + 'WORKING': ['working', {0: 'No', 1: 'Yes'}] } HM_PRESS_EVENTS = [ From 201c9fed773e0ad0a137ca67597af0685c85d1b1 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Sat, 14 Jul 2018 05:04:00 -0400 Subject: [PATCH 114/147] Implement is_on (#15459) * Implement is_on * Remove var --- homeassistant/components/switch/insteon_plm.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py index 42b4829f64ecd0..c357d1ccc0418e 100644 --- a/homeassistant/components/switch/insteon_plm.py +++ b/homeassistant/components/switch/insteon_plm.py @@ -46,8 +46,7 @@ class InsteonPLMSwitchDevice(InsteonPLMEntity, SwitchDevice): @property def is_on(self): """Return the boolean response if the node is on.""" - onlevel = self._insteon_device_state.value - return bool(onlevel) + return bool(self._insteon_device_state.value) @asyncio.coroutine def async_turn_on(self, **kwargs): @@ -63,6 +62,11 @@ def async_turn_off(self, **kwargs): class InsteonPLMOpenClosedDevice(InsteonPLMEntity, SwitchDevice): """A Class for an Insteon device.""" + @property + def is_on(self): + """Return the boolean response if the node is on.""" + return bool(self._insteon_device_state.value) + @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn device on.""" From 37ccf87516f44a1661488caf9edbb4039b380b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 15 Jul 2018 00:03:36 +0300 Subject: [PATCH 115/147] Remove unnecessary executable permissions (#15469) --- homeassistant/components/climate/fritzbox.py | 0 homeassistant/components/cover/group.py | 0 homeassistant/components/fritzbox.py | 0 homeassistant/components/sensor/wirelesstag.py | 0 homeassistant/components/switch/amcrest.py | 0 homeassistant/components/switch/fritzbox.py | 0 tests/components/cover/test_init.py | 0 tests/fixtures/pushbullet_devices.json | 0 8 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 homeassistant/components/climate/fritzbox.py mode change 100755 => 100644 homeassistant/components/cover/group.py mode change 100755 => 100644 homeassistant/components/fritzbox.py mode change 100755 => 100644 homeassistant/components/sensor/wirelesstag.py mode change 100755 => 100644 homeassistant/components/switch/amcrest.py mode change 100755 => 100644 homeassistant/components/switch/fritzbox.py mode change 100755 => 100644 tests/components/cover/test_init.py mode change 100755 => 100644 tests/fixtures/pushbullet_devices.json diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/cover/group.py b/homeassistant/components/cover/group.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/fritzbox.py b/homeassistant/components/fritzbox.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/sensor/wirelesstag.py b/homeassistant/components/sensor/wirelesstag.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/switch/amcrest.py b/homeassistant/components/switch/amcrest.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/switch/fritzbox.py b/homeassistant/components/switch/fritzbox.py old mode 100755 new mode 100644 diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py old mode 100755 new mode 100644 diff --git a/tests/fixtures/pushbullet_devices.json b/tests/fixtures/pushbullet_devices.json old mode 100755 new mode 100644 From ca4f69f5574cc444017532cdf9d3cf01b198fba1 Mon Sep 17 00:00:00 2001 From: huangyupeng Date: Sun, 15 Jul 2018 08:48:32 +0800 Subject: [PATCH 116/147] Add Tuya light platform (#15444) * add tuya light platform * fix as review required --- homeassistant/components/light/tuya.py | 102 +++++++++++++++++++++++++ homeassistant/components/tuya.py | 12 ++- requirements_all.txt | 2 +- 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/light/tuya.py diff --git a/homeassistant/components/light/tuya.py b/homeassistant/components/light/tuya.py new file mode 100644 index 00000000000000..d7691cea0118d6 --- /dev/null +++ b/homeassistant/components/light/tuya.py @@ -0,0 +1,102 @@ +""" +Support for the Tuya light. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.tuya/ +""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ENTITY_ID_FORMAT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, Light) + +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice +from homeassistant.util import color as colorutil + +DEPENDENCIES = ['tuya'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya light platform.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaLight(device)) + add_devices(devices) + + +class TuyaLight(TuyaDevice, Light): + """Tuya light device.""" + + def __init__(self, tuya): + """Init Tuya light device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + + @property + def brightness(self): + """Return the brightness of the light.""" + return self.tuya.brightness() + + @property + def hs_color(self): + """Return the hs_color of the light.""" + return self.tuya.hs_color() + + @property + def color_temp(self): + """Return the color_temp of the light.""" + color_temp = self.tuya.color_temp() + if color_temp is None: + return None + return colorutil.color_temperature_kelvin_to_mired(color_temp) + + @property + def is_on(self): + """Return true if light is on.""" + return self.tuya.state() + + @property + def min_mireds(self): + """Return color temperature min mireds.""" + return colorutil.color_temperature_kelvin_to_mired( + self.tuya.min_color_temp()) + + @property + def max_mireds(self): + """Return color temperature max mireds.""" + return colorutil.color_temperature_kelvin_to_mired( + self.tuya.max_color_temp()) + + def turn_on(self, **kwargs): + """Turn on or control the light.""" + if (ATTR_BRIGHTNESS not in kwargs + and ATTR_HS_COLOR not in kwargs + and ATTR_COLOR_TEMP not in kwargs): + self.tuya.turn_on() + if ATTR_BRIGHTNESS in kwargs: + self.tuya.set_brightness(kwargs[ATTR_BRIGHTNESS]) + if ATTR_HS_COLOR in kwargs: + self.tuya.set_color(kwargs[ATTR_HS_COLOR]) + if ATTR_COLOR_TEMP in kwargs: + color_temp = colorutil.color_temperature_mired_to_kelvin( + kwargs[ATTR_COLOR_TEMP]) + self.tuya.set_color_temp(color_temp) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self.tuya.turn_off() + + @property + def supported_features(self): + """Flag supported features.""" + supports = SUPPORT_BRIGHTNESS + if self.tuya.support_color(): + supports = supports | SUPPORT_COLOR + if self.tuya.support_color_temp(): + supports = supports | SUPPORT_COLOR_TEMP + return supports diff --git a/homeassistant/components/tuya.py b/homeassistant/components/tuya.py index 7263871e249a0a..c557774b5f1cad 100644 --- a/homeassistant/components/tuya.py +++ b/homeassistant/components/tuya.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval -REQUIREMENTS = ['tuyapy==0.1.1'] +REQUIREMENTS = ['tuyapy==0.1.2'] _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,8 @@ SERVICE_PULL_DEVICES = 'pull_devices' TUYA_TYPE_TO_HA = { - 'switch': 'switch' + 'light': 'light', + 'switch': 'switch', } CONFIG_SCHEMA = vol.Schema({ @@ -129,13 +130,18 @@ def object_id(self): """Return Tuya device id.""" return self.tuya.object_id() + @property + def unique_id(self): + """Return a unique ID.""" + return 'tuya.{}'.format(self.tuya.object_id()) + @property def name(self): """Return Tuya device name.""" return self.tuya.name() @property - def icon(self): + def entity_picture(self): """Return the entity picture to use in the frontend, if any.""" return self.tuya.iconurl() diff --git a/requirements_all.txt b/requirements_all.txt index 867f9d502b9a22..d688ef7adb1c62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1345,7 +1345,7 @@ total_connect_client==0.18 transmissionrpc==0.11 # homeassistant.components.tuya -tuyapy==0.1.1 +tuyapy==0.1.2 # homeassistant.components.twilio twilio==5.7.0 From 6db069881b30a000b54138af8e1cfd85f96cd6c7 Mon Sep 17 00:00:00 2001 From: Mattias Welponer Date: Sun, 15 Jul 2018 02:59:19 +0200 Subject: [PATCH 117/147] Update homematicip_cloud with enum states (#15460) * Update to next version with enum states * Change to generic dimmer class * Update of requirement files * Update to hmip lib to v0.9.7 * Missing update of requirements files * Cleanup of icon properties --- .../binary_sensor/homematicip_cloud.py | 6 +- .../components/homematicip_cloud/__init__.py | 2 +- .../components/light/homematicip_cloud.py | 4 +- .../components/sensor/homematicip_cloud.py | 65 ++++--------------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 20 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py index 72a7db1ac7a8ec..6966f61129c1df 100644 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -21,8 +21,6 @@ ATTR_MOTION_DETECTED = 'motion_detected' ATTR_ILLUMINATION = 'illumination' -HMIP_OPEN = 'open' - async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -61,11 +59,13 @@ def device_class(self): @property def is_on(self): """Return true if the shutter contact is on/open.""" + from homematicip.base.enums import WindowState + if self._device.sabotage: return True if self._device.windowState is None: return None - return self._device.windowState.lower() == HMIP_OPEN + return self._device.windowState == WindowState.OPEN class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 3ff4e438f53c7f..b9266322978079 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -19,7 +19,7 @@ from .hap import HomematicipHAP, HomematicipAuth # noqa: F401 from .device import HomematicipGenericDevice # noqa: F401 -REQUIREMENTS = ['homematicip==0.9.6'] +REQUIREMENTS = ['homematicip==0.9.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py index 9851248f7cc7d1..617a7209a865fc 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -31,14 +31,14 @@ async def async_setup_platform(hass, config, async_add_devices, async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the HomematicIP lights from a config entry.""" from homematicip.aio.device import ( - AsyncBrandSwitchMeasuring, AsyncPluggableDimmer) + AsyncBrandSwitchMeasuring, AsyncDimmer) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): devices.append(HomematicipLightMeasuring(home, device)) - elif isinstance(device, AsyncPluggableDimmer): + elif isinstance(device, AsyncDimmer): devices.append(HomematicipDimmer(home, device)) if devices: diff --git a/homeassistant/components/sensor/homematicip_cloud.py b/homeassistant/components/sensor/homematicip_cloud.py index 0596bc0b6ccd3e..87021e9c7c5f2e 100644 --- a/homeassistant/components/sensor/homematicip_cloud.py +++ b/homeassistant/components/sensor/homematicip_cloud.py @@ -24,14 +24,6 @@ ATTR_TEMPERATURE_OFFSET = 'temperature_offset' ATTR_HUMIDITY = 'humidity' -HMIP_UPTODATE = 'up_to_date' -HMIP_VALVE_DONE = 'adaption_done' -HMIP_SABOTAGE = 'sabotage' - -STATE_OK = 'ok' -STATE_LOW_BATTERY = 'low_battery' -STATE_SABOTAGE = 'sabotage' - async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -83,44 +75,17 @@ def available(self): """Device available.""" return self._home.connected + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return '%' + @property def device_state_attributes(self): """Return the state attributes of the access point.""" return {} -class HomematicipDeviceStatus(HomematicipGenericDevice): - """Representation of an HomematicIP device status.""" - - def __init__(self, home, device): - """Initialize generic status device.""" - super().__init__(home, device, 'Status') - - @property - def icon(self): - """Return the icon of the status device.""" - if (hasattr(self._device, 'sabotage') and - self._device.sabotage == HMIP_SABOTAGE): - return 'mdi:alert' - elif self._device.lowBat: - return 'mdi:battery-outline' - elif self._device.updateState.lower() != HMIP_UPTODATE: - return 'mdi:refresh' - return 'mdi:check' - - @property - def state(self): - """Return the state of the generic device.""" - if (hasattr(self._device, 'sabotage') and - self._device.sabotage == HMIP_SABOTAGE): - return STATE_SABOTAGE - elif self._device.lowBat: - return STATE_LOW_BATTERY - elif self._device.updateState.lower() != HMIP_UPTODATE: - return self._device.updateState.lower() - return STATE_OK - - class HomematicipHeatingThermostat(HomematicipGenericDevice): """MomematicIP heating thermostat representation.""" @@ -131,15 +96,19 @@ def __init__(self, home, device): @property def icon(self): """Return the icon.""" - if self._device.valveState.lower() != HMIP_VALVE_DONE: + from homematicip.base.enums import ValveState + + if self._device.valveState != ValveState.ADAPTION_DONE: return 'mdi:alert' return 'mdi:radiator' @property def state(self): """Return the state of the radiator valve.""" - if self._device.valveState.lower() != HMIP_VALVE_DONE: - return self._device.valveState.lower() + from homematicip.base.enums import ValveState + + if self._device.valveState != ValveState.ADAPTION_DONE: + return self._device.valveState return round(self._device.valvePosition*100) @property @@ -160,11 +129,6 @@ def device_class(self): """Return the device class of the sensor.""" return DEVICE_CLASS_HUMIDITY - @property - def icon(self): - """Return the icon.""" - return 'mdi:water-percent' - @property def state(self): """Return the state.""" @@ -188,11 +152,6 @@ def device_class(self): """Return the device class of the sensor.""" return DEVICE_CLASS_TEMPERATURE - @property - def icon(self): - """Return the icon.""" - return 'mdi:thermometer' - @property def state(self): """Return the state.""" diff --git a/requirements_all.txt b/requirements_all.txt index d688ef7adb1c62..49950f3f2e375f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -421,7 +421,7 @@ home-assistant-frontend==20180713.0 # homekit==0.6 # homeassistant.components.homematicip_cloud -homematicip==0.9.6 +homematicip==0.9.8 # homeassistant.components.google # homeassistant.components.remember_the_milk diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5967bd7c4dcceb..49e79f6b962b8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ holidays==0.9.5 home-assistant-frontend==20180713.0 # homeassistant.components.homematicip_cloud -homematicip==0.9.6 +homematicip==0.9.8 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From ed0cfc4f31924ecf3f5a4a2cf7789e5c209563db Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 15 Jul 2018 20:46:15 +0200 Subject: [PATCH 118/147] Add user via cmd line creates owner (#15470) * Add user via cmd line creates owner * Ensure access tokens are not verified for inactive users * Stale print * Lint --- homeassistant/auth/__init__.py | 53 +++++++++++------- homeassistant/auth/auth_store.py | 10 ++++ homeassistant/components/auth/__init__.py | 6 +++ homeassistant/components/http/auth.py | 5 -- tests/auth/test_init.py | 4 +- tests/components/auth/test_init.py | 37 +++---------- tests/components/auth/test_init_link_user.py | 56 ++++---------------- tests/components/test_websocket_api.py | 27 ++++++++++ 8 files changed, 97 insertions(+), 101 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index fb35bd05c336f8..b05fca164a0206 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -93,10 +93,15 @@ async def async_create_system_user(self, name): async def async_create_user(self, name): """Create a user.""" - return await self._store.async_create_user( - name=name, - is_active=True, - ) + kwargs = { + 'name': name, + 'is_active': True, + } + + if await self._user_should_be_owner(): + kwargs['is_owner'] = True + + return await self._store.async_create_user(**kwargs) async def async_get_or_create_user(self, credentials): """Get or create a user.""" @@ -116,20 +121,10 @@ async def async_get_or_create_user(self, credentials): info = await auth_provider.async_user_meta_for_credentials( credentials) - kwargs = { - 'credentials': credentials, - 'name': info.get('name') - } - - # Make owner and activate user if it's the first user. - if await self._store.async_get_users(): - kwargs['is_owner'] = False - kwargs['is_active'] = False - else: - kwargs['is_owner'] = True - kwargs['is_active'] = True - - return await self._store.async_create_user(**kwargs) + return await self._store.async_create_user( + credentials=credentials, + name=info.get('name'), + ) async def async_link_user(self, user, credentials): """Link credentials to an existing user.""" @@ -147,6 +142,14 @@ async def async_remove_user(self, user): await self._store.async_remove_user(user) + async def async_activate_user(self, user): + """Activate a user.""" + await self._store.async_activate_user(user) + + async def async_deactivate_user(self, user): + """Deactivate a user.""" + await self._store.async_deactivate_user(user) + async def async_remove_credentials(self, credentials): """Remove credentials.""" provider = self._async_get_auth_provider(credentials) @@ -191,7 +194,7 @@ def async_get_access_token(self, token): if tkn is None: return None - if tkn.expired: + if tkn.expired or not tkn.refresh_token.user.is_active: self._access_tokens.pop(token) return None @@ -218,3 +221,15 @@ def _async_get_auth_provider(self, credentials): auth_provider_key = (credentials.auth_provider_type, credentials.auth_provider_id) return self._providers.get(auth_provider_key) + + async def _user_should_be_owner(self): + """Determine if user should be owner. + + A user should be an owner if it is the first non-system user that is + being created. + """ + for user in await self._store.async_get_users(): + if not user.system_generated: + return False + + return True diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index ebd61140ac1ef1..8fd66d4bbb7dde 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -81,6 +81,16 @@ async def async_remove_user(self, user): self._users.pop(user.id) await self.async_save() + async def async_activate_user(self, user): + """Activate a user.""" + user.is_active = True + await self.async_save() + + async def async_deactivate_user(self, user): + """Activate a user.""" + user.is_active = False + await self.async_save() + async def async_remove_credentials(self, credentials): """Remove credentials.""" for user in self._users.values(): diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 1ead4cacdf0bac..f95880939337e1 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -275,6 +275,12 @@ async def _async_handle_auth_code(self, hass, client_id, data): }, status_code=400) user = await hass.auth.async_get_or_create_user(credentials) + + if not user.is_active: + return self.json({ + 'error': 'invalid_request', + }, status_code=400) + refresh_token = await hass.auth.async_create_refresh_token(user, client_id) access_token = hass.auth.async_create_access_token(refresh_token) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 46d77214160fcb..2cc62dce38ed70 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -106,11 +106,6 @@ async def async_validate_auth_header(request, api_password=None): if access_token is None: return False - user = access_token.refresh_token.user - - if not user.is_active: - return False - request['hass_user'] = access_token.refresh_token.user return True diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index f7187fd49fd08a..3e3662c13c48df 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -80,7 +80,7 @@ async def test_create_new_user(hass, hass_storage): credentials = step['result'] user = await manager.async_get_or_create_user(credentials) assert user is not None - assert user.is_owner is True + assert user.is_owner is False assert user.name == 'Test Name' @@ -198,7 +198,7 @@ async def test_saving_loading(hass, hass_storage): 'password': 'test-pass', }) user = await manager.async_get_or_create_user(step['result']) - + await manager.async_activate_user(user) refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) manager.async_create_access_token(refresh_token) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index c5c46d55e39971..59fc8714f77375 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -10,7 +10,7 @@ from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI -async def test_login_new_user_and_refresh_token(hass, aiohttp_client): +async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): """Test logging in with new user and refreshing tokens.""" client = await async_setup_auth(hass, aiohttp_client, setup_api=True) resp = await client.post('/auth/login_flow', json={ @@ -34,36 +34,13 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): # Exchange code for tokens resp = await client.post('/auth/token', data={ - 'client_id': CLIENT_ID, - 'grant_type': 'authorization_code', - 'code': code - }) - - assert resp.status == 200 - tokens = await resp.json() - - assert hass.auth.async_get_access_token(tokens['access_token']) is not None - - # Use refresh token to get more tokens. - resp = await client.post('/auth/token', data={ - 'client_id': CLIENT_ID, - 'grant_type': 'refresh_token', - 'refresh_token': tokens['refresh_token'] - }) - - assert resp.status == 200 - tokens = await resp.json() - assert 'refresh_token' not in tokens - assert hass.auth.async_get_access_token(tokens['access_token']) is not None - - # Test using access token to hit API. - resp = await client.get('/api/') - assert resp.status == 401 - - resp = await client.get('/api/', headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'client_id': CLIENT_ID, + 'grant_type': 'authorization_code', + 'code': code }) - assert resp.status == 200 + + # User is not active + assert resp.status == 400 def test_credential_store_expiration(): diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py index 28a924bb43a670..13515db87fab9c 100644 --- a/tests/components/auth/test_init_link_user.py +++ b/tests/components/auth/test_init_link_user.py @@ -25,40 +25,9 @@ async def async_get_code(hass, aiohttp_client): }] }] client = await async_setup_auth(hass, aiohttp_client, config) - - resp = await client.post('/auth/login_flow', json={ - 'client_id': CLIENT_ID, - 'handler': ['insecure_example', None], - 'redirect_uri': CLIENT_REDIRECT_URI, - }) - assert resp.status == 200 - step = await resp.json() - - resp = await client.post( - '/auth/login_flow/{}'.format(step['flow_id']), json={ - 'client_id': CLIENT_ID, - 'username': 'test-user', - 'password': 'test-pass', - }) - - assert resp.status == 200 - step = await resp.json() - code = step['result'] - - # Exchange code for tokens - resp = await client.post('/auth/token', data={ - 'client_id': CLIENT_ID, - 'grant_type': 'authorization_code', - 'code': code - }) - - assert resp.status == 200 - tokens = await resp.json() - - access_token = hass.auth.async_get_access_token(tokens['access_token']) - assert access_token is not None - user = access_token.refresh_token.user - assert len(user.credentials) == 1 + user = await hass.auth.async_create_user(name='Hello') + refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID) + access_token = hass.auth.async_create_access_token(refresh_token) # Now authenticate with the 2nd flow resp = await client.post('/auth/login_flow', json={ @@ -83,7 +52,7 @@ async def async_get_code(hass, aiohttp_client): 'user': user, 'code': step['result'], 'client': client, - 'tokens': tokens, + 'access_token': access_token.token, } @@ -92,18 +61,17 @@ async def test_link_user(hass, aiohttp_client): info = await async_get_code(hass, aiohttp_client) client = info['client'] code = info['code'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': CLIENT_ID, 'code': code }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 200 - assert len(info['user'].credentials) == 2 + assert len(info['user'].credentials) == 1 async def test_link_user_invalid_client_id(hass, aiohttp_client): @@ -111,36 +79,34 @@ async def test_link_user_invalid_client_id(hass, aiohttp_client): info = await async_get_code(hass, aiohttp_client) client = info['client'] code = info['code'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': 'invalid', 'code': code }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 400 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 async def test_link_user_invalid_code(hass, aiohttp_client): """Test linking a user to new credentials.""" info = await async_get_code(hass, aiohttp_client) client = info['client'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': CLIENT_ID, 'code': 'invalid' }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 400 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 async def test_link_user_invalid_auth(hass, aiohttp_client): @@ -156,4 +122,4 @@ async def test_link_user_invalid_auth(hass, aiohttp_client): }, headers={'authorization': 'Bearer invalid'}) assert resp.status == 401 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 6ea90bcdb88f24..dc1688bae166d0 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -341,6 +341,33 @@ async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): assert auth_msg['type'] == wapi.TYPE_AUTH_OK +async def test_auth_active_user_inactive(hass, aiohttp_client, + hass_access_token): + """Test authenticating with a token.""" + hass_access_token.refresh_token.user.is_active = False + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': hass_access_token.token + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID + + async def test_auth_active_with_password_not_allow(hass, aiohttp_client): """Test authenticating with a token.""" assert await async_setup_component(hass, 'websocket_api', { From 5995c6a2aca933917231cb1191e3831a13e642e7 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 15 Jul 2018 22:32:20 +0300 Subject: [PATCH 119/147] Switch to own packaged version of pygtfs (#15040) --- homeassistant/components/sensor/gtfs.py | 4 +--- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index 616144d2bc67ce..93e15b9cd5e27e 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -16,9 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ["https://github.com/robbiet480/pygtfs/archive/" - "00546724e4bbcb3053110d844ca44e2246267dd8.zip#" - "pygtfs==0.1.3"] +REQUIREMENTS = ['pygtfs-homeassistant==0.1.3.dev0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 49950f3f2e375f..49e5691bb6c2c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -427,9 +427,6 @@ homematicip==0.9.8 # homeassistant.components.remember_the_milk httplib2==0.10.3 -# homeassistant.components.sensor.gtfs -https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 - # homeassistant.components.hydrawise hydrawiser==0.1.1 @@ -837,6 +834,9 @@ pygatt==3.2.0 # homeassistant.components.cover.gogogate2 pygogogate2==0.1.1 +# homeassistant.components.sensor.gtfs +pygtfs-homeassistant==0.1.3.dev0 + # homeassistant.components.remote.harmony pyharmony==1.0.20 From 864a254071216e03d4ec9e81e9ca96bd914b1890 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 15 Jul 2018 23:09:05 +0200 Subject: [PATCH 120/147] Aware comments (#15480) * Make sure we cannot deactivate the owner * Use different error code when trying to fetch token for inactive user --- homeassistant/auth/__init__.py | 2 ++ homeassistant/components/auth/__init__.py | 7 +++++-- tests/auth/test_init.py | 11 +++++++++++ tests/components/auth/test_init.py | 5 ++++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index b05fca164a0206..9f342a50407b70 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -148,6 +148,8 @@ async def async_activate_user(self, user): async def async_deactivate_user(self, user): """Deactivate a user.""" + if user.is_owner: + raise ValueError('Unable to deactive the owner') await self._store.async_deactivate_user(user) async def async_remove_credentials(self, credentials): diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index f95880939337e1..6518c2bcc1c7fa 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -243,6 +243,7 @@ async def post(self, request): if client_id is None or not indieauth.verify_client_id(client_id): return self.json({ 'error': 'invalid_request', + 'error_description': 'Invalid client id', }, status_code=400) grant_type = data.get('grant_type') @@ -272,14 +273,16 @@ async def _async_handle_auth_code(self, hass, client_id, data): if credentials is None: return self.json({ 'error': 'invalid_request', + 'error_description': 'Invalid code', }, status_code=400) user = await hass.auth.async_get_or_create_user(credentials) if not user.is_active: return self.json({ - 'error': 'invalid_request', - }, status_code=400) + 'error': 'access_denied', + 'error_description': 'User is not active', + }, status_code=403) refresh_token = await hass.auth.async_create_refresh_token(user, client_id) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 3e3662c13c48df..cad4bbdbd7164f 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -291,3 +291,14 @@ async def test_refresh_token_not_requires_client_for_system_user(hass): token = await manager.async_create_refresh_token(user) assert token is not None assert token.client_id is None + + +async def test_cannot_deactive_owner(mock_hass): + """Test that we cannot deactive the owner.""" + manager = await auth.auth_manager_from_config(mock_hass, []) + owner = MockUser( + is_owner=True, + ).add_to_auth_manager(manager) + + with pytest.raises(ValueError): + await manager.async_deactivate_user(owner) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 59fc8714f77375..5f3a2d6478c828 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -40,7 +40,10 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): }) # User is not active - assert resp.status == 400 + assert resp.status == 403 + data = await resp.json() + assert data['error'] == 'access_denied' + assert data['error_description'] == 'User is not active' def test_credential_store_expiration(): From 7d0cc7e26c400da5b75805736cb22a8bac08ae96 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 15 Jul 2018 23:18:52 +0200 Subject: [PATCH 121/147] Fix flux_led turning on with color or effect (#15472) --- homeassistant/components/light/flux_led.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index b9db9d4f99b64d..c5cd9a8c4fdd60 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -218,6 +218,9 @@ def effect_list(self): def turn_on(self, **kwargs): """Turn the specified or all lights on.""" + if not self.is_on: + self._bulb.turnOn() + hs_color = kwargs.get(ATTR_HS_COLOR) if hs_color: @@ -269,9 +272,6 @@ def turn_on(self, **kwargs): else: self._bulb.setRgb(*tuple(rgb), brightness=brightness) - if not self.is_on: - self._bulb.turnOn() - def turn_off(self, **kwargs): """Turn the specified or all lights off.""" self._bulb.turnOff() From 60f780cc371651c57ef0049cd7b8c2f373d635bc Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 15 Jul 2018 23:24:35 +0200 Subject: [PATCH 122/147] Update limitlessled to 1.1.2 (#15481) --- 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 19aff97491e9b0..2263a865758060 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -21,7 +21,7 @@ color_temperature_mired_to_kelvin, color_hs_to_RGB) from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.1.0'] +REQUIREMENTS = ['limitlessled==1.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 49e5691bb6c2c1..a9835d0f3c3bd0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -505,7 +505,7 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.1.0 +limitlessled==1.1.2 # homeassistant.components.linode linode-api==4.1.9b1 From edf1f4466873dd340891595c43aca3c4c366157d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Jul 2018 08:50:21 +0200 Subject: [PATCH 123/147] Bump frontend to 20180716.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 76182dea6f9e37..61def9075f8a4d 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180713.0'] +REQUIREMENTS = ['home-assistant-frontend==20180716.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index a9835d0f3c3bd0..cde7060df74f9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180713.0 +home-assistant-frontend==20180716.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49e79f6b962b8b..08909b3bf6f0ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180713.0 +home-assistant-frontend==20180716.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From a1b478b3ac549834bc1dbc9308c4cd7700bb2f6b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Jul 2018 08:51:37 +0200 Subject: [PATCH 124/147] Version bump to 0.74.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cb6858639f45eb..182367f3890c61 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 = 73 +MINOR_VERSION = 74 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 7f1873926740f39a1cdd33114c98659a249b44a5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Jul 2018 08:51:52 +0200 Subject: [PATCH 125/147] Bumped version to 0.74.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 182367f3890c61..5b100414e48043 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 74 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 55f8b0a2f56d966a7c326257b25fddefce3f1b1c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Jul 2018 22:14:51 +0200 Subject: [PATCH 126/147] Bumped version to 0.74.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5b100414e48043..2ecd1d7b649fd4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 74 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From a14d8057ed2e37d066ed7747e2f540fc2c3823a9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Jul 2018 09:24:51 +0200 Subject: [PATCH 127/147] Add current user WS command (#15485) --- homeassistant/auth/__init__.py | 5 ++++ homeassistant/components/auth/__init__.py | 29 +++++++++++++++++++ homeassistant/components/frontend/__init__.py | 2 +- tests/components/auth/test_init.py | 27 +++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 9f342a50407b70..cc2f244efb4fa4 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -194,9 +194,14 @@ def async_get_access_token(self, token): tkn = self._access_tokens.get(token) if tkn is None: + _LOGGER.debug('Attempt to get non-existing access token') return None if tkn.expired or not tkn.refresh_token.user.is_active: + if tkn.expired: + _LOGGER.debug('Attempt to get expired access token') + else: + _LOGGER.debug('Attempt to get access token for inactive user') self._access_tokens.pop(token) return None diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 6518c2bcc1c7fa..84287c2e425a46 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -113,6 +113,7 @@ from homeassistant.core import callback from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView) +from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.util import dt as dt_util @@ -122,6 +123,12 @@ DOMAIN = 'auth' DEPENDENCIES = ['http'] + +WS_TYPE_CURRENT_USER = 'auth/current_user' +SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CURRENT_USER, +}) + _LOGGER = logging.getLogger(__name__) @@ -136,6 +143,11 @@ async def async_setup(hass, config): hass.http.register_view(GrantTokenView(retrieve_credentials)) hass.http.register_view(LinkUserView(retrieve_credentials)) + hass.components.websocket_api.async_register_command( + WS_TYPE_CURRENT_USER, websocket_current_user, + SCHEMA_WS_CURRENT_USER + ) + return True @@ -383,3 +395,20 @@ def retrieve_credentials(client_id, code): return None return store_credentials, retrieve_credentials + + +@callback +def websocket_current_user(hass, connection, msg): + """Return the current user.""" + user = connection.request.get('hass_user') + + if user is None: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'no_user', 'Not authenticated as a user')) + return + + connection.to_write.put_nowait(websocket_api.result_message(msg['id'], { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + })) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 61def9075f8a4d..89233b6c5181e6 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -257,7 +257,7 @@ def async_finalize_panel(panel): await asyncio.wait( [async_register_built_in_panel(hass, panel) for panel in ( 'dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace')], + 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace', 'profile')], loop=hass.loop) hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 5f3a2d6478c828..46b88e46b4dc93 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from homeassistant.components import auth @@ -66,3 +67,29 @@ def test_credential_store_expiration(): with patch('homeassistant.util.dt.utcnow', return_value=now + timedelta(minutes=9, seconds=59)): assert retrieve(client_id, code) == credentials + + +async def test_ws_current_user(hass, hass_ws_client, hass_access_token): + """Test the current user command.""" + assert await async_setup_component(hass, 'auth', { + 'http': { + 'api_password': 'bla' + } + }) + with patch('homeassistant.auth.AuthManager.active', return_value=True): + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth.WS_TYPE_CURRENT_USER, + }) + + result = await client.receive_json() + assert result['success'], result + + user = hass_access_token.refresh_token.user + user_dict = result['result'] + + assert user_dict['name'] == user.name + assert user_dict['id'] == user.id + assert user_dict['is_owner'] == user.is_owner From a4318682f764948ac02c94bb0e46ff65bcd5b1a1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Jul 2018 10:49:15 +0200 Subject: [PATCH 128/147] Add onboarding support (#15492) * Add onboarding support * Lint * Address comments * Mark user step as done if owner user already created --- homeassistant/auth/providers/homeassistant.py | 3 + homeassistant/components/frontend/__init__.py | 14 +- .../components/onboarding/__init__.py | 56 +++++++ homeassistant/components/onboarding/const.py | 7 + homeassistant/components/onboarding/views.py | 106 ++++++++++++++ tests/components/onboarding/__init__.py | 11 ++ tests/components/onboarding/test_init.py | 77 ++++++++++ tests/components/onboarding/test_views.py | 137 ++++++++++++++++++ 8 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/onboarding/__init__.py create mode 100644 homeassistant/components/onboarding/const.py create mode 100644 homeassistant/components/onboarding/views.py create mode 100644 tests/components/onboarding/__init__.py create mode 100644 tests/components/onboarding/test_init.py create mode 100644 tests/components/onboarding/test_views.py diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 17a56bc5f423ea..b359f67d77fd1a 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -150,6 +150,9 @@ class HassAuthProvider(AuthProvider): async def async_initialize(self): """Initialize the auth provider.""" + if self.data is not None: + return + self.data = Data(self.hass) await self.data.async_load() diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 89233b6c5181e6..958247cadc5f08 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -29,7 +29,7 @@ REQUIREMENTS = ['home-assistant-frontend==20180716.0'] DOMAIN = 'frontend' -DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] +DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', 'onboarding'] CONF_THEMES = 'themes' CONF_EXTRA_HTML_URL = 'extra_html_url' @@ -377,6 +377,16 @@ async def get(self, request, extra=None): latest = self.repo_path is not None or \ _is_latest(self.js_option, request) + if not hass.components.onboarding.async_is_onboarded(): + if latest: + location = '/frontend_latest/onboarding.html' + else: + location = '/frontend_es5/onboarding.html' + + return web.Response(status=302, headers={ + 'location': location + }) + no_auth = '1' if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: # do not try to auto connect on load @@ -480,7 +490,7 @@ def websocket_get_translations(hass, connection, msg): Async friendly. """ async def send_translations(): - """Send a camera still.""" + """Send a translation.""" resources = await async_get_translations(hass, msg['language']) connection.send_message_outside(websocket_api.result_message( msg['id'], { diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py new file mode 100644 index 00000000000000..6dea5919f096d6 --- /dev/null +++ b/homeassistant/components/onboarding/__init__.py @@ -0,0 +1,56 @@ +"""Component to help onboard new users.""" +from homeassistant.core import callback +from homeassistant.loader import bind_hass + +from .const import STEPS, STEP_USER, DOMAIN + +DEPENDENCIES = ['http'] +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + + +@bind_hass +@callback +def async_is_onboarded(hass): + """Return if Home Assistant has been onboarded.""" + # Temporarily: if auth not active, always set onboarded=True + if not hass.auth.active: + return True + + return hass.data.get(DOMAIN, True) + + +async def async_setup(hass, config): + """Set up the onboard component.""" + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + data = await store.async_load() + + if data is None: + data = { + 'done': [] + } + + if STEP_USER not in data['done']: + # Users can already have created an owner account via the command line + # If so, mark the user step as done. + has_owner = False + + for user in await hass.auth.async_get_users(): + if user.is_owner: + has_owner = True + break + + if has_owner: + data['done'].append(STEP_USER) + await store.async_save(data) + + if set(data['done']) == set(STEPS): + return True + + hass.data[DOMAIN] = False + + from . import views + + await views.async_setup(hass, data, store) + + return True diff --git a/homeassistant/components/onboarding/const.py b/homeassistant/components/onboarding/const.py new file mode 100644 index 00000000000000..3aa106ac18c4d9 --- /dev/null +++ b/homeassistant/components/onboarding/const.py @@ -0,0 +1,7 @@ +"""Constants for the onboarding component.""" +DOMAIN = 'onboarding' +STEP_USER = 'user' + +STEPS = [ + STEP_USER +] diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py new file mode 100644 index 00000000000000..1a536a1bc43ca8 --- /dev/null +++ b/homeassistant/components/onboarding/views.py @@ -0,0 +1,106 @@ +"""Onboarding views.""" +import asyncio + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + +from .const import DOMAIN, STEPS, STEP_USER + + +async def async_setup(hass, data, store): + """Setup onboarding.""" + hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(UserOnboardingView(data, store)) + + +class OnboardingView(HomeAssistantView): + """Returns the onboarding status.""" + + requires_auth = False + url = '/api/onboarding' + name = 'api:onboarding' + + def __init__(self, data, store): + """Initialize the onboarding view.""" + self._store = store + self._data = data + + async def get(self, request): + """Return the onboarding status.""" + return self.json([ + { + 'step': key, + 'done': key in self._data['done'], + } for key in STEPS + ]) + + +class _BaseOnboardingView(HomeAssistantView): + """Base class for onboarding.""" + + requires_auth = False + step = None + + def __init__(self, data, store): + """Initialize the onboarding view.""" + self._store = store + self._data = data + self._lock = asyncio.Lock() + + @callback + def _async_is_done(self): + """Return if this step is done.""" + return self.step in self._data['done'] + + async def _async_mark_done(self, hass): + """Mark step as done.""" + self._data['done'].append(self.step) + await self._store.async_save(self._data) + + hass.data[DOMAIN] = len(self._data) == len(STEPS) + + +class UserOnboardingView(_BaseOnboardingView): + """View to handle onboarding.""" + + url = '/api/onboarding/users' + name = 'api:onboarding:users' + step = STEP_USER + + @RequestDataValidator(vol.Schema({ + vol.Required('name'): str, + vol.Required('username'): str, + vol.Required('password'): str, + })) + async def post(self, request, data): + """Return the manifest.json.""" + hass = request.app['hass'] + + async with self._lock: + if self._async_is_done(): + return self.json_message('User step already done', 403) + + provider = _async_get_hass_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_create_user(data['name']) + await hass.async_add_executor_job( + provider.data.add_auth, data['username'], data['password']) + credentials = await provider.async_get_or_create_credentials({ + 'username': data['username'] + }) + await hass.auth.async_link_user(user, credentials) + await self._async_mark_done(hass) + + +@callback +def _async_get_hass_provider(hass): + """Get the Home Assistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == 'homeassistant': + return prv + + raise RuntimeError('No Home Assistant provider found') diff --git a/tests/components/onboarding/__init__.py b/tests/components/onboarding/__init__.py new file mode 100644 index 00000000000000..62c6dc929a1052 --- /dev/null +++ b/tests/components/onboarding/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the onboarding component.""" + +from homeassistant.components import onboarding + + +def mock_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + 'version': onboarding.STORAGE_VERSION, + 'data': data + } diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py new file mode 100644 index 00000000000000..57a81a78da34b7 --- /dev/null +++ b/tests/components/onboarding/test_init.py @@ -0,0 +1,77 @@ +"""Tests for the init.""" +from unittest.mock import patch, Mock + +from homeassistant.setup import async_setup_component +from homeassistant.components import onboarding + +from tests.common import mock_coro, MockUser + +from . import mock_storage + +# Temporarily: if auth not active, always set onboarded=True + + +async def test_not_setup_views_if_onboarded(hass, hass_storage): + """Test if onboarding is done, we don't setup views.""" + mock_storage(hass_storage, { + 'done': onboarding.STEPS + }) + + with patch( + 'homeassistant.components.onboarding.views.async_setup' + ) as mock_setup: + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 0 + assert onboarding.DOMAIN not in hass.data + assert onboarding.async_is_onboarded(hass) + + +async def test_setup_views_if_not_onboarded(hass): + """Test if onboarding is not done, we setup views.""" + with patch( + 'homeassistant.components.onboarding.views.async_setup', + return_value=mock_coro() + ) as mock_setup: + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 1 + assert onboarding.DOMAIN in hass.data + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + assert not onboarding.async_is_onboarded(hass) + + +async def test_is_onboarded(): + """Test the is onboarded function.""" + hass = Mock() + hass.data = {} + + with patch('homeassistant.auth.AuthManager.active', return_value=False): + assert onboarding.async_is_onboarded(hass) + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + assert onboarding.async_is_onboarded(hass) + + hass.data[onboarding.DOMAIN] = True + assert onboarding.async_is_onboarded(hass) + + hass.data[onboarding.DOMAIN] = False + assert not onboarding.async_is_onboarded(hass) + + +async def test_having_owner_finishes_user_step(hass, hass_storage): + """If owner user already exists, mark user step as complete.""" + MockUser(is_owner=True).add_to_hass(hass) + + with patch( + 'homeassistant.components.onboarding.views.async_setup' + ) as mock_setup, patch.object(onboarding, 'STEPS', [onboarding.STEP_USER]): + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 0 + assert onboarding.DOMAIN not in hass.data + assert onboarding.async_is_onboarded(hass) + + done = hass_storage[onboarding.STORAGE_KEY]['data']['done'] + assert onboarding.STEP_USER in done diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py new file mode 100644 index 00000000000000..d6a4030190de42 --- /dev/null +++ b/tests/components/onboarding/test_views.py @@ -0,0 +1,137 @@ +"""Test the onboarding views.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import onboarding +from homeassistant.components.onboarding import views + +from tests.common import register_auth_provider + +from . import mock_storage + + +@pytest.fixture(autouse=True) +def auth_active(hass): + """Ensure auth is always active.""" + hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant' + })) + + +async def test_onboarding_progress(hass, hass_storage, aiohttp_client): + """Test fetching progress.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + client = await aiohttp_client(hass.http.app) + + with patch.object(views, 'STEPS', ['hello', 'world']): + resp = await client.get('/api/onboarding') + + assert resp.status == 200 + data = await resp.json() + assert len(data) == 2 + assert data[0] == { + 'step': 'hello', + 'done': True + } + assert data[1] == { + 'step': 'world', + 'done': False + } + + +async def test_onboarding_user_already_done(hass, hass_storage, + aiohttp_client): + """Test creating a new user when user step already done.""" + mock_storage(hass_storage, { + 'done': [views.STEP_USER] + }) + + with patch.object(onboarding, 'STEPS', ['hello', 'world']): + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'name': 'Test Name', + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 403 + + +async def test_onboarding_user(hass, hass_storage, aiohttp_client): + """Test creating a new user.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'name': 'Test Name', + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 200 + users = await hass.auth.async_get_users() + assert len(users) == 1 + user = users[0] + assert user.name == 'Test Name' + assert len(user.credentials) == 1 + assert user.credentials[0].data['username'] == 'test-user' + + +async def test_onboarding_user_invalid_name(hass, hass_storage, + aiohttp_client): + """Test not providing name.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 400 + + +async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): + """Test race condition on creating new user.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp1 = client.post('/api/onboarding/users', json={ + 'name': 'Test 1', + 'username': '1-user', + 'password': '1-pass', + }) + resp2 = client.post('/api/onboarding/users', json={ + 'name': 'Test 2', + 'username': '2-user', + 'password': '2-pass', + }) + + res1, res2 = await asyncio.gather(resp1, resp2) + + assert sorted([res1.status, res2.status]) == [200, 403] From 8b475f45e9cfd6b62df8662a2559180ae543bfa0 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Tue, 17 Jul 2018 01:06:06 -0700 Subject: [PATCH 129/147] Update HomeKit module code (#15502) This fixes a bunch of bugs, including issues with concurrency in devices that present multiple accessories, devices that insist on the TLV entries being in the order that Apple use, and handling devices that send headers and data in separate chunks. This should improve compatibility with a whole bunch of HomeKit devices. --- homeassistant/components/homekit_controller/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 237a6d219f0c3e..5e24fe82340626 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['homekit==0.6'] +REQUIREMENTS = ['homekit==0.10'] DOMAIN = 'homekit_controller' HOMEKIT_DIR = '.homekit' diff --git a/requirements_all.txt b/requirements_all.txt index cde7060df74f9d..20419f4f5d65f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -418,7 +418,7 @@ holidays==0.9.5 home-assistant-frontend==20180716.0 # homeassistant.components.homekit_controller -# homekit==0.6 +# homekit==0.10 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 20c316bce4b575c25089a14681cc9178df9a4cf2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Jul 2018 10:57:05 +0200 Subject: [PATCH 130/147] Bump frontend to 20180717.0 --- homeassistant/components/frontend/__init__.py | 2 +- homeassistant/components/onboarding/views.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 958247cadc5f08..141da89f3595e2 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180716.0'] +REQUIREMENTS = ['home-assistant-frontend==20180717.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', 'onboarding'] diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 1a536a1bc43ca8..17d83003c48983 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -92,6 +92,7 @@ async def post(self, request, data): credentials = await provider.async_get_or_create_credentials({ 'username': data['username'] }) + await provider.data.async_save() await hass.auth.async_link_user(user, credentials) await self._async_mark_done(hass) diff --git a/requirements_all.txt b/requirements_all.txt index 20419f4f5d65f5..76198d54bc61e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180716.0 +home-assistant-frontend==20180717.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08909b3bf6f0ce..ad3877392c7b02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180716.0 +home-assistant-frontend==20180717.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 5dc29bd2c3836d7947deb386302f4d5b1271a0e1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Jul 2018 10:59:07 +0200 Subject: [PATCH 131/147] Bumped version to 0.74.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2ecd1d7b649fd4..9466e94998bafc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 74 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 61273ff6060f8b0f296e72e9232ef63d569431e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Jul 2018 17:33:44 +0200 Subject: [PATCH 132/147] Bump frontend to 20180718.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 141da89f3595e2..5a3ac0d16b50fb 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180717.0'] +REQUIREMENTS = ['home-assistant-frontend==20180718.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', 'onboarding'] diff --git a/requirements_all.txt b/requirements_all.txt index 76198d54bc61e3..db81024a84a537 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180717.0 +home-assistant-frontend==20180718.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad3877392c7b02..01f90202e8fb21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180717.0 +home-assistant-frontend==20180718.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From e64761b15e746ee645d42da34ee1922e5411e696 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Tue, 17 Jul 2018 10:36:33 -0700 Subject: [PATCH 133/147] Disallow use insecure_example auth provider in configuration.yml (#15504) * Disallow use insecure_example auth provider in configuration.yml * Add unit test for auth provider config validate --- homeassistant/config.py | 9 +++++++-- tests/test_config.py | 44 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 2afa943ee50688..d9206d622507d5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -20,7 +20,7 @@ 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, - CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS) + CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS, CONF_TYPE) from homeassistant.core import callback, DOMAIN as CONF_CORE from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import get_component, get_platform @@ -160,7 +160,12 @@ vol.All(cv.ensure_list, [vol.IsDir()]), vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, vol.Optional(CONF_AUTH_PROVIDERS): - vol.All(cv.ensure_list, [auth_providers.AUTH_PROVIDER_SCHEMA]) + vol.All(cv.ensure_list, + [auth_providers.AUTH_PROVIDER_SCHEMA.extend({ + CONF_TYPE: vol.NotIn(['insecure_example'], + 'The insecure_example auth provider' + ' is for testing only.') + })]) }) diff --git a/tests/test_config.py b/tests/test_config.py index 717a3f62ec9a33..435d3a00ec2146 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,7 @@ from collections import OrderedDict import pytest -from voluptuous import MultipleInvalid +from voluptuous import MultipleInvalid, Invalid from homeassistant.core import DOMAIN, HomeAssistantError, Config import homeassistant.config as config_util @@ -15,7 +15,8 @@ ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME, CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, - CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT) + CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, + CONF_AUTH_PROVIDERS) from homeassistant.util import location as location_util, dt as dt_util from homeassistant.util.yaml import SECRET_YAML from homeassistant.util.async_ import run_coroutine_threadsafe @@ -790,3 +791,42 @@ def test_merge_customize(hass): assert hass.data[config_util.DATA_CUSTOMIZE].get('b.b') == \ {'friendly_name': 'BB'} + + +async def test_auth_provider_config(hass): + """Test loading auth provider config onto hass object.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_PROVIDERS: [ + {'type': 'homeassistant'}, + {'type': 'legacy_api_password'}, + ] + } + if hasattr(hass, 'auth'): + del hass.auth + await config_util.async_process_ha_core_config(hass, core_config) + + assert len(hass.auth.auth_providers) == 2 + assert hass.auth.active is True + + +async def test_disallowed_auth_provider_config(hass): + """Test loading insecure example auth provider is disallowed.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_PROVIDERS: [ + {'type': 'insecure_example'}, + ] + } + with pytest.raises(Invalid): + await config_util.async_process_ha_core_config(hass, core_config) From 7b8ad64ba570504be9b5ba44f485820c026a1bca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Jul 2018 17:41:36 +0200 Subject: [PATCH 134/147] Bumped version to 0.74.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9466e94998bafc..fb3aed504491cc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 74 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0b3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 5a1360678b745256c6751d88a9926a9740f0c4d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Jul 2018 10:52:28 +0200 Subject: [PATCH 135/147] Bump frontend to 20180719.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 5a3ac0d16b50fb..dc5d1d7bf7e3f1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180718.0'] +REQUIREMENTS = ['home-assistant-frontend==20180719.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', 'onboarding'] diff --git a/requirements_all.txt b/requirements_all.txt index db81024a84a537..bf7042d8b9de25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180718.0 +home-assistant-frontend==20180719.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01f90202e8fb21..e1e1593b814206 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180718.0 +home-assistant-frontend==20180719.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 9c337bc621613c2daefe63b87a119fcdcf7b7015 Mon Sep 17 00:00:00 2001 From: Jerad Meisner Date: Thu, 19 Jul 2018 00:39:51 -0700 Subject: [PATCH 136/147] Added WS endpoint for changing homeassistant password. (#15527) * Added WS endpoint for changing homeassistant password. * Remove change password helper. Don't require current password. * Restore current password verification. * Added tests. * Use correct send method --- .../config/auth_provider_homeassistant.py | 54 ++++++++++++++ .../test_auth_provider_homeassistant.py | 74 +++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index fca03ad8fa9f01..960e8f5e7b4e42 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -20,6 +20,13 @@ vol.Required('username'): str, }) +WS_TYPE_CHANGE_PASSWORD = 'config/auth_provider/homeassistant/change_password' +SCHEMA_WS_CHANGE_PASSWORD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CHANGE_PASSWORD, + vol.Required('current_password'): str, + vol.Required('new_password'): str +}) + async def async_setup(hass): """Enable the Home Assistant views.""" @@ -31,6 +38,10 @@ async def async_setup(hass): WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CHANGE_PASSWORD, websocket_change_password, + SCHEMA_WS_CHANGE_PASSWORD + ) return True @@ -118,3 +129,46 @@ async def delete_creds(): websocket_api.result_message(msg['id'])) hass.async_add_job(delete_creds()) + + +@callback +def websocket_change_password(hass, connection, msg): + """Change user password.""" + async def change_password(): + """Change user password.""" + user = connection.request.get('hass_user') + if user is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'user_not_found', 'User not found')) + return + + provider = _get_provider(hass) + await provider.async_initialize() + + username = None + for credential in user.credentials: + if credential.auth_provider_type == provider.type: + username = credential.data['username'] + break + + if username is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'credentials_not_found', 'Credentials not found')) + return + + try: + await provider.async_validate_login( + username, msg['current_password']) + except auth_ha.InvalidAuth: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'invalid_password', 'Invalid password')) + return + + await hass.async_add_executor_job( + provider.data.change_password, username, msg['new_password']) + await provider.data.async_save() + + connection.send_message_outside( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(change_password()) diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py index fa4ab612bb12c5..cd2cbc44539967 100644 --- a/tests/components/config/test_auth_provider_homeassistant.py +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -227,3 +227,77 @@ async def test_delete_unknown_auth(hass, hass_ws_client, hass_access_token): result = await client.receive_json() assert not result['success'], result assert result['error']['code'] == 'auth_not_found' + + +async def test_change_password(hass, hass_ws_client, hass_access_token): + """Test that change password succeeds with valid password.""" + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await hass.async_add_executor_job( + provider.data.add_auth, 'test-user', 'test-pass') + + credentials = await provider.async_get_or_create_credentials({ + 'username': 'test-user' + }) + + user = hass_access_token.refresh_token.user + await hass.auth.async_link_user(user, credentials) + + client = await hass_ws_client(hass, hass_access_token) + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'test-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert result['success'], result + await provider.async_validate_login('test-user', 'new-pass') + + +async def test_change_password_wrong_pw(hass, hass_ws_client, + hass_access_token): + """Test that change password fails with invalid password.""" + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await hass.async_add_executor_job( + provider.data.add_auth, 'test-user', 'test-pass') + + credentials = await provider.async_get_or_create_credentials({ + 'username': 'test-user' + }) + + user = hass_access_token.refresh_token.user + await hass.auth.async_link_user(user, credentials) + + client = await hass_ws_client(hass, hass_access_token) + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'wrong-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'invalid_password' + with pytest.raises(prov_ha.InvalidAuth): + await provider.async_validate_login('test-user', 'new-pass') + + +async def test_change_password_no_creds(hass, hass_ws_client, + hass_access_token): + """Test that change password fails with no credentials.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'test-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'credentials_not_found' From dff2e4ebc229aff606ba5f4f5039b9cb729542b6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Jul 2018 23:00:26 +0200 Subject: [PATCH 137/147] Don't be so strict client-side (#15546) --- homeassistant/util/ssl.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index fc02009b7af7ae..4f528cfcb51644 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -6,21 +6,14 @@ def client_context(): """Return an SSL context for making requests.""" - context = _get_context() - context.verify_mode = ssl.CERT_REQUIRED - context.check_hostname = True - context.load_verify_locations(cafile=certifi.where(), capath=None) + context = ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, + cafile=certifi.where() + ) return context def server_context(): - """Return an SSL context for being a server.""" - context = _get_context() - context.options |= ssl.OP_CIPHER_SERVER_PREFERENCE - return context - - -def _get_context(): """Return an SSL context following the Mozilla recommendations. TLS configuration follows the best-practice guidelines specified here: @@ -31,7 +24,8 @@ def _get_context(): context.options |= ( ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | - ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | + ssl.OP_CIPHER_SERVER_PREFERENCE ) if hasattr(ssl, 'OP_NO_COMPRESSION'): context.options |= ssl.OP_NO_COMPRESSION From ca0d4226aa6a382662996a41a4004434350e76f5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Jul 2018 10:47:06 +0200 Subject: [PATCH 138/147] Decouple emulated hue from http server (#15530) --- .../components/emulated_hue/__init__.py | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 6988e20fb5f0dd..ce94a560daee39 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,6 +6,7 @@ """ import logging +from aiohttp import web import voluptuous as vol from homeassistant import util @@ -13,7 +14,6 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.components.http import REQUIREMENTS # NOQA -from homeassistant.components.http import HomeAssistantHTTP from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv @@ -85,28 +85,17 @@ def setup(hass, yaml_config): """Activate the emulated_hue component.""" config = Config(hass, yaml_config.get(DOMAIN, {})) - server = HomeAssistantHTTP( - hass, - server_host=config.host_ip_addr, - server_port=config.listen_port, - api_password=None, - ssl_certificate=None, - ssl_peer_certificate=None, - ssl_key=None, - cors_origins=None, - use_x_forwarded_for=False, - trusted_proxies=[], - trusted_networks=[], - login_threshold=0, - is_ban_enabled=False - ) - - server.register_view(DescriptionXmlView(config)) - server.register_view(HueUsernameView) - server.register_view(HueAllLightsStateView(config)) - server.register_view(HueOneLightStateView(config)) - server.register_view(HueOneLightChangeView(config)) - server.register_view(HueGroupView(config)) + app = web.Application() + app['hass'] = hass + handler = None + server = None + + DescriptionXmlView(config).register(app.router) + HueUsernameView().register(app.router) + HueAllLightsStateView(config).register(app.router) + HueOneLightStateView(config).register(app.router) + HueOneLightChangeView(config).register(app.router) + HueGroupView(config).register(app.router) upnp_listener = UPNPResponderThread( config.host_ip_addr, config.listen_port, @@ -116,14 +105,31 @@ def setup(hass, yaml_config): async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - await server.stop() + if server: + server.close() + await server.wait_closed() + await app.shutdown() + if handler: + await handler.shutdown(10) + await app.cleanup() async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - await server.start() - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) + nonlocal handler + nonlocal server + + handler = app.make_handler(loop=hass.loop) + + try: + server = await hass.loop.create_server( + handler, config.host_ip_addr, config.listen_port) + except OSError as error: + _LOGGER.error("Failed to create HTTP server at port %d: %s", + config.listen_port, error) + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) From 2fc0d83085bdd70e04d9ebb90b9a5ed79874a01e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Jul 2018 08:37:00 +0200 Subject: [PATCH 139/147] Allow CORS requests to token endpoint (#15519) * Allow CORS requests to token endpoint * Tests * Fuck emulated hue * Clean up * Only cors existing methods --- homeassistant/components/auth/__init__.py | 1 + .../components/emulated_hue/__init__.py | 12 ++++++------ homeassistant/components/http/__init__.py | 5 ++--- homeassistant/components/http/cors.py | 14 ++++++++++++++ homeassistant/components/http/view.py | 19 ++++++++++++------- tests/components/auth/test_init.py | 17 +++++++++++++++++ tests/components/emulated_hue/test_hue_api.py | 8 ++++---- tests/components/http/test_cors.py | 4 ++-- tests/components/http/test_data_validator.py | 2 +- 9 files changed, 59 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 84287c2e425a46..435555c2e31440 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -241,6 +241,7 @@ class GrantTokenView(HomeAssistantView): url = '/auth/token' name = 'api:auth:token' requires_auth = False + cors_allowed = True def __init__(self, retrieve_credentials): """Initialize the grant token view.""" diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index ce94a560daee39..36ce1c392f99ee 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -90,12 +90,12 @@ def setup(hass, yaml_config): handler = None server = None - DescriptionXmlView(config).register(app.router) - HueUsernameView().register(app.router) - HueAllLightsStateView(config).register(app.router) - HueOneLightStateView(config).register(app.router) - HueOneLightChangeView(config).register(app.router) - HueGroupView(config).register(app.router) + DescriptionXmlView(config).register(app, app.router) + HueUsernameView().register(app, app.router) + HueAllLightsStateView(config).register(app, app.router) + HueOneLightStateView(config).register(app, app.router) + HueOneLightChangeView(config).register(app, app.router) + HueGroupView(config).register(app, app.router) upnp_listener = UPNPResponderThread( config.host_ip_addr, config.listen_port, diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index c8eba41e66b0ea..0cbee628a8a9c7 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -187,8 +187,7 @@ def __init__(self, hass, api_password, support_legacy=hass.auth.support_legacy, api_password=api_password) - if cors_origins: - setup_cors(app, cors_origins) + setup_cors(app, cors_origins) app['hass'] = hass @@ -226,7 +225,7 @@ def register_view(self, view): '{0} missing required attribute "name"'.format(class_name) ) - view.register(self.app.router) + view.register(self.app, self.app.router) def register_redirect(self, url, redirect_to): """Register a redirect with the server. diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 0a37f22867efc4..b01e68f701d953 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -27,6 +27,20 @@ def setup_cors(app, origins): ) for host in origins }) + def allow_cors(route, methods): + """Allow cors on a route.""" + cors.add(route, { + '*': aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, + allow_methods=methods, + ) + }) + + app['allow_cors'] = allow_cors + + if not origins: + return + async def cors_startup(app): """Initialize cors when app starts up.""" cors_added = set() diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 3de276564ebb87..23698af810139a 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -26,7 +26,9 @@ class HomeAssistantView(object): url = None extra_urls = [] - requires_auth = True # Views inheriting from this class can override this + # Views inheriting from this class can override this + requires_auth = True + cors_allowed = False # pylint: disable=no-self-use def json(self, result, status_code=200, headers=None): @@ -51,10 +53,11 @@ def json_message(self, message, status_code=200, message_code=None, data['code'] = message_code return self.json(data, status_code, headers=headers) - def register(self, router): + def register(self, app, router): """Register the view with a router.""" assert self.url is not None, 'No url set for view' urls = [self.url] + self.extra_urls + routes = [] for method in ('get', 'post', 'delete', 'put'): handler = getattr(self, method, None) @@ -65,13 +68,15 @@ def register(self, router): handler = request_handler_factory(self, handler) for url in urls: - router.add_route(method, url, handler) + routes.append( + (method, router.add_route(method, url, handler)) + ) - # aiohttp_cors does not work with class based views - # self.app.router.add_route('*', self.url, self, name=self.name) + if not self.cors_allowed: + return - # for url in self.extra_urls: - # self.app.router.add_route('*', url, self) + for method, route in routes: + app['allow_cors'](route, [method.upper()]) def request_handler_factory(view, handler): diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 46b88e46b4dc93..1d3719b8c66e1b 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -93,3 +93,20 @@ async def test_ws_current_user(hass, hass_ws_client, hass_access_token): assert user_dict['name'] == user.name assert user_dict['id'] == user.id assert user_dict['is_owner'] == user.is_owner + + +async def test_cors_on_token(hass, aiohttp_client): + """Test logging in with new user and refreshing tokens.""" + client = await async_setup_auth(hass, aiohttp_client) + + resp = await client.options('/auth/token', headers={ + 'origin': 'http://example.com', + 'Access-Control-Request-Method': 'POST', + }) + assert resp.headers['Access-Control-Allow-Origin'] == 'http://example.com' + assert resp.headers['Access-Control-Allow-Methods'] == 'POST' + + resp = await client.post('/auth/token', headers={ + 'origin': 'http://example.com' + }) + assert resp.headers['Access-Control-Allow-Origin'] == 'http://example.com' diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 1617f327d27baa..c99d273a4580a7 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -130,10 +130,10 @@ def hue_client(loop, hass_hue, aiohttp_client): } }) - HueUsernameView().register(web_app.router) - HueAllLightsStateView(config).register(web_app.router) - HueOneLightStateView(config).register(web_app.router) - HueOneLightChangeView(config).register(web_app.router) + HueUsernameView().register(web_app, web_app.router) + HueAllLightsStateView(config).register(web_app, web_app.router) + HueOneLightStateView(config).register(web_app, web_app.router) + HueOneLightChangeView(config).register(web_app, web_app.router) return loop.run_until_complete(aiohttp_client(web_app)) diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 27367b4173e3f1..523d4943ba04a6 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -19,14 +19,14 @@ TRUSTED_ORIGIN = 'https://home-assistant.io' -async def test_cors_middleware_not_loaded_by_default(hass): +async def test_cors_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_cors') as mock_setup: await async_setup_component(hass, 'http', { 'http': {} }) - assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 1 async def test_cors_middleware_loaded_from_config(hass): diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index 2b966daff6cf9a..b5eed19eb6136c 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -23,7 +23,7 @@ async def post(self, request, data): """Test method.""" return b'' - TestView().register(app.router) + TestView().register(app, app.router) client = await aiohttp_client(app) return client From 7aa2a9e50667ca57936e02048a3fbc9fe84ae323 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Jul 2018 12:26:15 +0200 Subject: [PATCH 140/147] Bumped version to 0.74.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fb3aed504491cc..e541d8e1954a62 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 74 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0b4' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 9fb04b5280c49fa714c97696b490353d52d7c045 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Jul 2018 12:30:10 +0200 Subject: [PATCH 141/147] Update the frontend to 20180720.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index dc5d1d7bf7e3f1..68e88406ad69d3 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180719.0'] +REQUIREMENTS = ['home-assistant-frontend==20180720.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', 'onboarding'] diff --git a/requirements_all.txt b/requirements_all.txt index bf7042d8b9de25..97c3d5835805f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180719.0 +home-assistant-frontend==20180720.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1e1593b814206..3de2285eae9d65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180719.0 +home-assistant-frontend==20180720.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 61b3822374a258242c3f9c1ae7650cc73787b8b7 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 19 Jul 2018 22:52:03 +0200 Subject: [PATCH 142/147] Upgrade pymysensors to 0.16.0 (#15554) --- homeassistant/components/mysensors/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 3aa8e82911eff5..3066819638f5ba 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -22,7 +22,7 @@ from .device import get_mysensors_devices from .gateway import get_mysensors_gateway, setup_gateways, finish_setup -REQUIREMENTS = ['pymysensors==0.14.0'] +REQUIREMENTS = ['pymysensors==0.16.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 97c3d5835805f0..72dc74b0f66cc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -926,7 +926,7 @@ pymusiccast==0.1.6 pymyq==0.0.11 # homeassistant.components.mysensors -pymysensors==0.14.0 +pymysensors==0.16.0 # homeassistant.components.lock.nello pynello==1.5.1 From b3bed7fb37c232851003668be34b31afa77e164a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Jul 2018 22:10:36 +0200 Subject: [PATCH 143/147] Allow auth providers to influence is_active (#15557) * Allow auth providers to influence is_active * Fix auth script test --- homeassistant/auth/__init__.py | 1 + homeassistant/auth/providers/__init__.py | 4 +++ homeassistant/auth/providers/homeassistant.py | 3 +- .../auth/providers/insecure_example.py | 10 ++++--- .../auth/providers/legacy_api_password.py | 5 +++- homeassistant/scripts/auth.py | 9 +----- tests/auth/providers/test_homeassistant.py | 18 +++++++++++ tests/auth/providers/test_insecure_example.py | 17 +++++++++-- .../providers/test_legacy_api_password.py | 6 +++- tests/components/auth/test_init.py | 30 +++++++++++++++---- tests/scripts/test_auth.py | 2 +- 11 files changed, 82 insertions(+), 23 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index cc2f244efb4fa4..62c416a988300d 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -124,6 +124,7 @@ async def async_get_or_create_user(self, credentials): return await self._store.async_create_user( credentials=credentials, name=info.get('name'), + is_active=info.get('is_active', False) ) async def async_link_user(self, user, credentials): diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 3769248fc05352..68cc1c7edd2722 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -135,5 +135,9 @@ async def async_user_meta_for_credentials(self, credentials): """Return extra user metadata for credentials. Will be used to populate info when creating a new user. + + Values to populate: + - name: string + - is_active: boolean """ return {} diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index b359f67d77fd1a..d24110a47362e0 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -184,7 +184,8 @@ async def async_get_or_create_credentials(self, flow_result): async def async_user_meta_for_credentials(self, credentials): """Get extra info for this credential.""" return { - 'name': credentials.data['username'] + 'name': credentials.data['username'], + 'is_active': True, } async def async_will_remove_credentials(self, credentials): diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index e06b16177a1241..c86c8eb71f1124 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -75,14 +75,16 @@ async def async_user_meta_for_credentials(self, credentials): Will be used to populate info when creating a new user. """ username = credentials.data['username'] + info = { + 'is_active': True, + } for user in self.config['users']: if user['username'] == username: - return { - 'name': user.get('name') - } + info['name'] = user.get('name') + break - return {} + return info class LoginFlow(data_entry_flow.FlowHandler): diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 57c05e3bdc86bc..1f92fb60f13752 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -70,7 +70,10 @@ async def async_user_meta_for_credentials(self, credentials): Will be used to populate info when creating a new user. """ - return {'name': LEGACY_USER} + return { + 'name': LEGACY_USER, + 'is_active': True, + } class LoginFlow(data_entry_flow.FlowHandler): diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index fea523c4117a18..d141faa4c27637 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -81,16 +81,9 @@ async def add_user(hass, provider, args): print("Username already exists!") return - credentials = await provider.async_get_or_create_credentials({ - 'username': args.username - }) - - user = await hass.auth.async_create_user(args.username) - await hass.auth.async_link_user(user, credentials) - # Save username/password await provider.data.async_save() - print("User created") + print("Auth created") async def validate_login(hass, provider, args): diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index 08fb63a3c72770..9db6293d98a14f 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -4,6 +4,7 @@ import pytest from homeassistant import data_entry_flow +from homeassistant.auth import auth_manager_from_config from homeassistant.auth.providers import ( auth_provider_from_config, homeassistant as hass_auth) @@ -112,3 +113,20 @@ async def test_not_allow_set_id(): 'id': 'invalid', }) assert provider is None + + +async def test_new_users_populate_values(hass, data): + """Test that we populate data for new users.""" + data.add_auth('hello', 'test-pass') + await data.async_save() + + manager = await auth_manager_from_config(hass, [{ + 'type': 'homeassistant' + }]) + provider = manager.auth_providers[0] + credentials = await provider.async_get_or_create_credentials({ + 'username': 'hello' + }) + user = await manager.async_get_or_create_user(credentials) + assert user.name == 'hello' + assert user.is_active diff --git a/tests/auth/providers/test_insecure_example.py b/tests/auth/providers/test_insecure_example.py index 8e8c9738756567..b472e4c95df3b4 100644 --- a/tests/auth/providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -4,7 +4,7 @@ import pytest -from homeassistant.auth import auth_store, models as auth_models +from homeassistant.auth import auth_store, models as auth_models, AuthManager from homeassistant.auth.providers import insecure_example from tests.common import mock_coro @@ -23,6 +23,7 @@ def provider(hass, store): 'type': 'insecure_example', 'users': [ { + 'name': 'Test Name', 'username': 'user-test', 'password': 'password-test', }, @@ -34,7 +35,15 @@ def provider(hass, store): }) -async def test_create_new_credential(provider): +@pytest.fixture +def manager(hass, store, provider): + """Mock manager.""" + return AuthManager(hass, store, { + (provider.type, provider.id): provider + }) + + +async def test_create_new_credential(manager, provider): """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials({ 'username': 'user-test', @@ -42,6 +51,10 @@ async def test_create_new_credential(provider): }) assert credentials.is_new is True + user = await manager.async_get_or_create_user(credentials) + assert user.name == 'Test Name' + assert user.is_active + async def test_match_existing_credentials(store, provider): """See if we match existing users.""" diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py index 007e37b90c4e3e..71642bd7a32c26 100644 --- a/tests/auth/providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -30,12 +30,16 @@ def manager(hass, store, provider): }) -async def test_create_new_credential(provider): +async def test_create_new_credential(manager, provider): """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials({}) assert credentials.data["username"] is legacy_api_password.LEGACY_USER assert credentials.is_new is True + user = await manager.async_get_or_create_user(credentials) + assert user.name == legacy_api_password.LEGACY_USER + assert user.is_active + async def test_only_one_credentials(manager, provider): """Call create twice will return same credential.""" diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 1d3719b8c66e1b..807bf15854b977 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -40,11 +40,31 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): 'code': code }) - # User is not active - assert resp.status == 403 - data = await resp.json() - assert data['error'] == 'access_denied' - assert data['error_description'] == 'User is not active' + assert resp.status == 200 + tokens = await resp.json() + + assert hass.auth.async_get_access_token(tokens['access_token']) is not None + + # Use refresh token to get more tokens. + resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, + 'grant_type': 'refresh_token', + 'refresh_token': tokens['refresh_token'] + }) + + assert resp.status == 200 + tokens = await resp.json() + assert 'refresh_token' not in tokens + assert hass.auth.async_get_access_token(tokens['access_token']) is not None + + # Test using access token to hit API. + resp = await client.get('/api/') + assert resp.status == 401 + + resp = await client.get('/api/', headers={ + 'authorization': 'Bearer {}'.format(tokens['access_token']) + }) + assert resp.status == 200 def test_credential_store_expiration(): diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index 1320be299b83cb..f6c027150dd8ff 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -47,7 +47,7 @@ async def test_add_user(hass, provider, capsys, hass_storage): assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 captured = capsys.readouterr() - assert captured.out == 'User created\n' + assert captured.out == 'Auth created\n' assert len(data.users) == 1 data.validate_login('paulus', 'test-pass') From eff334a1d01d35d65078724df242110b23eac708 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Jul 2018 23:12:17 +0200 Subject: [PATCH 144/147] Remove relative time from state machine (#15560) --- homeassistant/components/sensor/netatmo.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index bdc2c5990d9256..54b095bb84bb97 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -51,7 +51,6 @@ 'rf_status_lvl': ['Radio_lvl', '', 'mdi:signal', None], 'wifi_status': ['Wifi', '', 'mdi:wifi', None], 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi', None], - 'lastupdated': ['Last Updated', 's', 'mdi:timer', None], } MODULE_SCHEMA = vol.Schema({ @@ -286,8 +285,6 @@ def update(self): self._state = "High" elif data['wifi_status'] <= 55: self._state = "Full" - elif self.type == 'lastupdated': - self._state = int(time() - data['When']) class NetAtmoData(object): From 2aa54ce22b01447651743396bc1c16d7bd44cf20 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 20 Jul 2018 03:09:48 -0700 Subject: [PATCH 145/147] Reset failed login attempts counter when login success (#15564) --- homeassistant/components/http/ban.py | 29 +++++++++++- homeassistant/components/http/view.py | 8 +++- homeassistant/components/websocket_api.py | 4 +- tests/components/http/test_ban.py | 58 ++++++++++++++++++++++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index fe8b7db84d1b95..e05f951322e94b 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -72,7 +72,11 @@ async def ban_middleware(request, handler): async def process_wrong_login(request): - """Process a wrong login attempt.""" + """Process a wrong login attempt. + + Increase failed login attempts counter for remote IP address. + Add ip ban entry if failed login attempts exceeds threshold. + """ remote_addr = request[KEY_REAL_IP] msg = ('Login attempt or request with invalid authentication ' @@ -107,7 +111,28 @@ async def process_wrong_login(request): 'Banning IP address', NOTIFICATION_ID_BAN) -class IpBan(object): +async def process_success_login(request): + """Process a success login attempt. + + Reset failed login attempts counter for remote IP address. + No release IP address from banned list function, it can only be done by + manual modify ip bans config file. + """ + remote_addr = request[KEY_REAL_IP] + + # Check if ban middleware is loaded + if (KEY_BANNED_IPS not in request.app or + request.app[KEY_LOGIN_THRESHOLD] < 1): + return + + if remote_addr in request.app[KEY_FAILED_LOGIN_ATTEMPTS] and \ + request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > 0: + _LOGGER.debug('Login success, reset failed login attempts counter' + ' from %s', remote_addr) + request.app[KEY_FAILED_LOGIN_ATTEMPTS].pop(remote_addr) + + +class IpBan: """Represents banned IP address.""" def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 23698af810139a..7823d674ab3cbc 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -12,6 +12,7 @@ from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError import homeassistant.remote as rem +from homeassistant.components.http.ban import process_success_login from homeassistant.core import is_callback from homeassistant.const import CONTENT_TYPE_JSON @@ -91,8 +92,11 @@ async def handle(request): authenticated = request.get(KEY_AUTHENTICATED, False) - if view.requires_auth and not authenticated: - raise HTTPUnauthorized() + if view.requires_auth: + if authenticated: + await process_success_login(request) + else: + raise HTTPUnauthorized() _LOGGER.info('Serving %s to %s (auth: %s)', request.path, request.get(KEY_REAL_IP), authenticated) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 6cd1690904136f..98e3057338a6de 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -26,7 +26,8 @@ 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 +from homeassistant.components.http.ban import process_wrong_login, \ + process_success_login DOMAIN = 'websocket_api' @@ -360,6 +361,7 @@ def handle_hass_stop(event): return wsock self.debug("Auth OK") + await process_success_login(request) await self.wsock.send_json(auth_ok_message()) # ---------- AUTH PHASE OVER ---------- diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index c5691cf3e2ac27..a6a07928113b34 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -1,14 +1,18 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access -from unittest.mock import patch, mock_open +from ipaddress import ip_address +from unittest.mock import patch, mock_open, Mock from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized +from aiohttp.web_middlewares import middleware +from homeassistant.components.http import KEY_AUTHENTICATED +from homeassistant.components.http.view import request_handler_factory from homeassistant.setup import async_setup_component import homeassistant.components.http as http from homeassistant.components.http.ban import ( - IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS) + IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS, KEY_FAILED_LOGIN_ATTEMPTS) from . import mock_real_ip @@ -88,3 +92,53 @@ async def unauth_handler(request): resp = await client.get('/') assert resp.status == 403 assert m.call_count == 1 + + +async def test_failed_login_attempts_counter(hass, aiohttp_client): + """Testing if failed login attempts counter increased.""" + app = web.Application() + app['hass'] = hass + + async def auth_handler(request): + """Return 200 status code.""" + return None, 200 + + app.router.add_get('/auth_true', request_handler_factory( + Mock(requires_auth=True), auth_handler)) + app.router.add_get('/auth_false', request_handler_factory( + Mock(requires_auth=True), auth_handler)) + app.router.add_get('/', request_handler_factory( + Mock(requires_auth=False), auth_handler)) + + setup_bans(hass, app, 5) + remote_ip = ip_address("200.201.202.204") + mock_real_ip(app)("200.201.202.204") + + @middleware + async def mock_auth(request, handler): + """Mock auth middleware.""" + if 'auth_true' in request.path: + request[KEY_AUTHENTICATED] = True + else: + request[KEY_AUTHENTICATED] = False + return await handler(request) + + app.middlewares.append(mock_auth) + + client = await aiohttp_client(app) + + resp = await client.get('/auth_false') + assert resp.status == 401 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 1 + + resp = await client.get('/auth_false') + assert resp.status == 401 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + resp = await client.get('/') + assert resp.status == 200 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + resp = await client.get('/auth_true') + assert resp.status == 200 + assert remote_ip not in app[KEY_FAILED_LOGIN_ATTEMPTS] From 8e659baf250f19b7858068f33cce109181b11222 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Jul 2018 12:44:15 +0200 Subject: [PATCH 146/147] Bumped version to 0.74.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e541d8e1954a62..1627910f9bb5cd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 74 -PATCH_VERSION = '0b4' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 200c0a87781d702d5a8308b06a25aed8d30807e8 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 20 Jul 2018 14:40:10 +0200 Subject: [PATCH 147/147] light.tplink: initialize min & max mireds only once, avoid i/o outside update (#15571) * light.tplink: initialize min & max mireds only once, avoid i/o outside update * revert the index change * fix indent, sorry for overwriting your fix, balloob --- homeassistant/components/light/tplink.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 09a4fa3610d8c4..669901f5b57cbf 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -66,6 +66,8 @@ def __init__(self, smartbulb: 'SmartBulb', name) -> None: self._brightness = None self._hs = None self._supported_features = 0 + self._min_mireds = None + self._max_mireds = None self._emeter_params = {} @property @@ -107,12 +109,12 @@ def turn_off(self, **kwargs): @property def min_mireds(self): """Return minimum supported color temperature.""" - return kelvin_to_mired(self.smartbulb.valid_temperature_range[1]) + return self._min_mireds @property def max_mireds(self): """Return maximum supported color temperature.""" - return kelvin_to_mired(self.smartbulb.valid_temperature_range[0]) + return self._max_mireds @property def color_temp(self): @@ -195,5 +197,9 @@ def get_features(self): self._supported_features += SUPPORT_BRIGHTNESS if self.smartbulb.is_variable_color_temp: self._supported_features += SUPPORT_COLOR_TEMP + self._min_mireds = kelvin_to_mired( + self.smartbulb.valid_temperature_range[1]) + self._max_mireds = kelvin_to_mired( + self.smartbulb.valid_temperature_range[0]) if self.smartbulb.is_color: self._supported_features += SUPPORT_COLOR