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 9c05028b394..79a91c18cd4 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -11,6 +11,7 @@ from numbers import Number from functools import partial from copy import copy from datetime import timedelta +import math import voluptuous as vol @@ -28,6 +29,7 @@ import homeassistant.util.dt as dt_util _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_WINDOW_SIZE = 'window_size' 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_WINDOW_SIZE = 1 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 @@ FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ [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 @@ class Filter(object): 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 8e79306fe13..09b56ed6be4 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -131,6 +131,22 @@ class TestFilterSensor(unittest.TestCase): 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 79a91c18cd4..4505a794883 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -85,6 +85,8 @@ FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ 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 09b56ed6be4..e43df67c84f 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -4,7 +4,8 @@ import unittest 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 @@ class TestFilterSensor(unittest.TestCase): """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 4505a794883..9346480b4b3 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 @@ class BandPassFilter(Filter): 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 9346480b4b3..f1fb1fca6f3 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -29,7 +29,7 @@ import homeassistant.util.dt as dt_util _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 @@ FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ FILTER_LOWPASS_SCHEMA, FILTER_TIME_SMA_SCHEMA, FILTER_THROTTLE_SCHEMA, - FILTER_BANDPASS_SCHEMA)]) + FILTER_RANGE_SCHEMA)]) }) @@ -342,11 +342,11 @@ class Filter(object): 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 e43df67c84f..7b232101289 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -5,7 +5,7 @@ from unittest.mock import patch 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 @@ class TestFilterSensor(unittest.TestCase): 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 7b232101289..4d52648582f 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -137,9 +137,9 @@ class TestFilterSensor(unittest.TestCase): 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 f1fb1fca6f3..88868ddcc42 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -364,25 +364,24 @@ class RangeFilter(Filter): 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 4d52648582f..0f5b581d075 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -140,14 +140,14 @@ class TestFilterSensor(unittest.TestCase): 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 88868ddcc42..21fd09248ff 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -374,7 +374,7 @@ class RangeFilter(Filter): 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 0f5b581d075..e449f239c0b 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -141,13 +141,14 @@ class TestFilterSensor(unittest.TestCase): 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 21fd09248ff..93380291a3e 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -364,7 +364,6 @@ class RangeFilter(Filter): 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 e449f239c0b..6fc732b28f5 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -141,7 +141,7 @@ class TestFilterSensor(unittest.TestCase): 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 93380291a3e..49e9189a84d 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -380,7 +380,7 @@ class RangeFilter(Filter): _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 6fc732b28f5..718c39764d0 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -141,14 +141,14 @@ class TestFilterSensor(unittest.TestCase): 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 49e9189a84d..770287228a2 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -85,8 +85,6 @@ FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ 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 718c39764d0..cf2cc9c4205 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -136,8 +136,7 @@ class TestFilterSensor(unittest.TestCase): """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 05907ea86ee..d6078490e7f 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -174,7 +174,6 @@ class DeconzLight(Light): 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 84edd9afd40..ba91dd7c1fc 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -40,6 +40,8 @@ TAHOMA_TYPES = { '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 bf440728a2e..7c7ff3480b0 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -107,6 +107,20 @@ SENSOR_TYPES = { ['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 246cf3a96c2..889984eb223 100644 --- a/homeassistant/components/watson_iot.py +++ b/homeassistant/components/watson_iot.py @@ -4,7 +4,6 @@ A component which allows you to send data to the IBM Watson IoT Platform. 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 time 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 @@ REQUIREMENTS = ['ibmiotf==0.3.4'] _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 setup(hass, config): }, 'time': event.time_fired.isoformat(), 'fields': { - 'state': state.state + 'state': state.state, } } if _state_as_value is not None: @@ -113,7 +112,7 @@ def setup(hass, config): 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 @@ class WatsonIOTThread(threading.Thread): 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 @@ class WatsonIOTThread(threading.Thread): 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 @@ class WatsonIOTThread(threading.Thread): 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 1ed502e0f7f..87821b802ba 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -4,7 +4,6 @@ Support for Lutron Caseta shades. 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): """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 235ff5799cc..62e1069e18b 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -4,7 +4,6 @@ Support for MQTT cover devices. 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ }).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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 @@ class MqttCover(MqttAvailability, CoverDevice): # 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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 @@ class MqttCover(MqttAvailability, CoverDevice): 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 a9b7598159f..3357bf2d204 100644 --- a/homeassistant/components/cover/rflink.py +++ b/homeassistant/components/cover/rflink.py @@ -4,7 +4,6 @@ Support for Rflink Cover devices. 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 4e197365a70..d9d0d61c77a 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -4,7 +4,6 @@ Support for covers which integrate with other components. 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@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 @@ class CoverTemplate(CoverDevice): 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 @@ class CoverTemplate(CoverDevice): """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 ab5d6e8ef79..fd060e7a7e1 100644 --- a/homeassistant/components/cover/velbus.py +++ b/homeassistant/components/cover/velbus.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.velbus/ """ import logging -import asyncio import time import voluptuous as vol @@ -70,15 +69,14 @@ class VelbusCover(CoverDevice): 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 7f7a3a11644..2206de05041 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -4,8 +4,6 @@ Support for Wink Covers. 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 | 151 +++++++++++++++--- tests/components/test_websocket_api.py | 108 ++++++++++--- 8 files changed, 468 insertions(+), 83 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 22abcdf213c..f56e00bf31e 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -280,6 +280,18 @@ class AuthManager: """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 @@ class AuthStore: 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 00000000000..510cc4d0279 --- /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 485433434fd..37a6805dfb5 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -184,7 +184,22 @@ class HomeAssistantHTTP(object): 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 c4723abccee..a232d9295a4 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -17,37 +17,44 @@ _LOGGER = logging.getLogger(__name__) @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 bf472348bab..c26f68a2c29 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -315,26 +315,32 @@ class ActiveConnection: 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 @@ class ActiveConnection: 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 @@ class ActiveConnection: 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 00000000000..7a8f17894aa --- /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 dd8b2cd35c4..3e5eed4c924 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.auth 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 from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component -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 . 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,15 +150,12 @@ 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(app) - client = await aiohttp_client(app) + set_mock_ip = mock_real_ip(app2) + client = await aiohttp_client(app2) for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) @@ -146,3 +168,94 @@ 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_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('/') + 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_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 fbd8584a7d1..6ea90bcdb88 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_active_with_password_not_allow(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', + 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: + 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 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 - } - }) + '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': 'incorrect' - }) + 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 + 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 7ee0e166cf2..d6e92d5b8ff 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 7bb05bbdd00..bdd96562206 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 8354757ff33..65fa7c8cb0f 100644 --- a/homeassistant/components/weather/openweathermap.py +++ b/homeassistant/components/weather/openweathermap.py @@ -156,6 +156,8 @@ class OpenWeatherMapWeather(WeatherEntity): 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 @@ class WeatherData(object): 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 d6078490e7f..08d7f5773f7 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -174,6 +174,7 @@ class DeconzLight(Light): 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 2abd445bb85..7813cc5c047 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 3833f90f2d1..928d894c9d1 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 @@ PROJECT_URLS = { '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 @@ setup( 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 5aa53f17e7b..4357c4109eb 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -137,7 +137,8 @@ class IntentHandler: 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 d9238336768..baeda2c49a8 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -5,6 +5,7 @@ import logging 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 a8d37a249bc..707129ae531 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 | 324 +++++++++--------- requirements_all.txt | 4 +- 4 files changed, 586 insertions(+), 158 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 00000000000..cc3079c6e53 --- /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 00000000000..b3b2d05e933 --- /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 dc661c3e5bf..5f0ca995c90 100644 --- a/homeassistant/components/switch/rachio.py +++ b/homeassistant/components/switch/rachio.py @@ -4,227 +4,239 @@ 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/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) - - 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 - - hass.data[DATA_RACHIO] = devices[0] - - 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") - - hass.data[DATA_RACHIO].update() - add_devices(hass.data[DATA_RACHIO].list_zones()) + add_devices(devices) + _LOGGER.info("%d Rachio switch(es) added", len(devices)) -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] +class RachioSwitch(SwitchDevice): + """Represent a Rachio state that can be toggled.""" + def __init__(self, controller, poll=True): + """Initialize a new Rachio switch.""" + self._controller = controller -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']] - - -class RachioIro(object): - """Representation of a Rachio Iro.""" - - 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) + if poll: + self._state = self._poll_update() + else: + self._state = None @property - def device_id(self): - """Return the Rachio API device ID.""" - return self._device['id'] + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False @property - def status(self): - """Return the current status of the device.""" - return self._device['status'] + def name(self) -> str: + """Get a name for this switch.""" + return "Switch on {}".format(self._controller.name) @property - def serial_number(self): - """Return the serial number of the device.""" - return self._device['serialNumber'] + 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 _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) + + @abstractmethod + def _handle_update(self, *args, **kwargs) -> None: + """Handle incoming webhook data.""" + pass + + +class RachioStandbySwitch(RachioSwitch): + """Representation of a standby status/button.""" + + 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 is_paused(self): - """Return whether the device is temporarily disabled.""" - return self._device['paused'] + def name(self) -> str: + """Return the name of the standby switch.""" + return "{} in standby mode".format(self._controller.name) @property - def is_on(self): - """Return whether the device is powered on and connected.""" - return self._device['on'] + def icon(self) -> str: + """Return an icon for the standby switch.""" + return "mdi:power" - @property - def current_schedule(self): - """Return the schedule that the device is running right now.""" - return self._running + 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] - 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']] + return not data[KEY_ON] - if include_disabled: - return self._zones + 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 - self.update(no_throttle=True) - return [z for z in self._zones if z.is_enabled] + self.schedule_update_ha_state() - @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] + def turn_on(self, **kwargs) -> None: + """Put the controller in standby mode.""" + self._controller.rachio.device.off(self._controller.controller_id) - # Possibly update all zones - for zone in self.list_zones(include_disabled=True): - zone.update() - - _LOGGER.debug("Updated %s", str(self)) + def turn_off(self, **kwargs) -> None: + """Resume controller functionality.""" + self._controller.rachio.device.on(self._controller.controller_id) -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'] - - @property - def name(self): + def name(self) -> str: """Return the friendly name of the zone.""" - return self._zone['name'] + return self._zone_name @property - def is_enabled(self): + def icon(self) -> str: + """Return the icon to display.""" + return "mdi:water" + + @property + 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 update(self): - """Pull updated zone info from the Rachio API.""" - self._zone = self.rachio.zone.get(self._zone_id)[1] - - # Possibly update device - self._device.update() - - _LOGGER.debug("Updated %s", str(self)) - - def turn_on(self, **kwargs): - """Start the zone.""" + def turn_on(self, **kwargs) -> None: + """Start watering this zone.""" # Stop other zones first self.turn_off() - _LOGGER.info("Watering %s for %d s", self.name, self._manual_run_secs) - self.rachio.zone.start(self.zone_id, self._manual_run_secs) + # 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) - def turn_off(self, **kwargs): - """Stop all zones.""" - _LOGGER.info("Stopping watering of all zones") - self.rachio.device.stopWater(self._device.device_id) + def turn_off(self, **kwargs) -> None: + """Stop watering all zones.""" + self._controller.stop_watering() + + 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 _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()) + + 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 + + self.schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 1267a702811..b173dc1e56b 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 65079a1a26e..05bc3cbd01c 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -10,10 +10,8 @@ from aiohttp.hdrs import AUTHORIZATION 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 f20d4f747cc..927139a483e 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -3,14 +3,6 @@ import collections 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 42568a6b9ad..c75c40dd929 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -123,7 +123,7 @@ class TibberSensor(Entity): 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 2906f07a307..52ff0e19c59 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -7,7 +7,7 @@ import os 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 9114a4db941..7ab90b7a048 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 f1335f73346..81ec046f2e9 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 e3e41e09db2..9e5efffdccb 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -16,17 +16,11 @@ import logging 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 84118e57c8f..7bad8ff727d 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 b173dc1e56b..b011bd6747e 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 bdd96562206..a0a51fdc203 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 38ba593261f..43c2aa5c7b1 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,7 +35,7 @@ from . import migration, purge 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 7fefb0f450b..8574a7231da 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity _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 b011bd6747e..6184bed0224 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 a0a51fdc203..6bfe170c67c 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 fc40d17d0af..0b059379c11 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -18,7 +18,7 @@ import homeassistant.helpers.location as location 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 @@ REGIONS = ['US', 'NA', 'EU', 'IL'] 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class WazeTravelTime(Entity): 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 @@ class WazeTravelTime(Entity): 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 6184bed0224..af71ea2ee54 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 9da352e1268..31460c1eedc 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -31,12 +31,10 @@ CAMERA_BINARY_TYPES = { 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 @@ class NestBinarySensor(NestSensorDevice, BinarySensorDevice): 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 421356f07bc..9b2c183c1d1 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -446,7 +446,9 @@ class LIFXLight(Light): @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 @@ class LIFXColor(LIFXLight): 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 | 105 ++++++++++++------ homeassistant/components/frontend/__init__.py | 2 +- tests/common.py | 10 +- tests/components/auth/__init__.py | 2 +- tests/test_auth.py | 53 +++++++-- 5 files changed, 121 insertions(+), 51 deletions(-) diff --git a/homeassistant/auth.py b/homeassistant/auth.py index f56e00bf31e..a4e8ee05943 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 @@ class AuthManager: 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_user(self, user_id): - """Retrieve a user.""" - if self.users is None: + async def async_get_users(self): + """Retrieve all users.""" + if self._users is None: await self.async_load() - return self.users.get(user_id) + return list(self._users.values()) + + async def async_get_user(self, user_id): + """Retrieve a user.""" + 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. @@ -422,7 +438,7 @@ class AuthStore: 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 @@ class AuthStore: 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 @@ class AuthStore: 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 @@ class AuthStore: 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 @@ class AuthStore: 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 @@ class AuthStore: 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 @@ class AuthStore: 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_client(self, client_id): - """Get a client.""" - if self.clients is None: + async def async_get_clients(self): + """Return all clients.""" + if self._clients is None: await self.async_load() - return self.clients.get(client_id) + 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.""" @@ -516,12 +547,12 @@ class AuthStore: # 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 @@ class AuthStore: 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 @@ class AuthStore: '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 @@ class AuthStore: '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 @@ class AuthStore: 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 @@ class AuthStore: '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 @@ class AuthStore: '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 7bad8ff727d..9a32626c66a 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 1b8eabaa0db..3a51cd3e059 100644 --- a/tests/common.py +++ b/tests/common.py @@ -321,7 +321,7 @@ class MockUser(auth.User): 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 @@ class MockUser(auth.User): 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 f0b205ff5ce..21719c12569 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 4c0db71466e..5b545223c15 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 0cb49fde54e..15504ea57af 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 8c98844cf93..df1ee662124 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -20,8 +20,7 @@ from homeassistant.const import ( 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 af71ea2ee54..08985bff945 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 f97d7051459..72deabaae28 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -27,14 +27,15 @@ from homeassistant.components.climate.ecobee import ( 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_ATTRIBUTES = { 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 8b034346475..ca82c83d0fc 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 44d22e03f41..750c2c0ae0a 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -284,7 +284,9 @@ class RoombaVacuum(VacuumDevice): 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 00000000000..82f063b365f --- /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 00000000000..2572c3344eb --- /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 00000000000..f59a1b43ef1 --- /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 00000000000..21c8e60518e --- /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 00000000000..f1daff83069 --- /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 00000000000..91c428770f5 --- /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 00000000000..24a7215574d --- /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 00000000000..711ac320397 --- /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 0721cac3321..1588766e406 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 9d3dc9e6e62..b09b7e15b31 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 46190d23926..3de7de9ddb3 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 90d13bb39b4..6f3fa2ec9a4 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 59c5577c96b..bc7a2cbd861 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 17cbe87f1e8..5cd1a14d499 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 d466488e9fc..dc0968dc88a 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 ea1e4fff1bf..b471dd1a0cd 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 00000000000..c884226174b --- /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 00000000000..721eafa807f --- /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 00000000000..abf8f79599f --- /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 00000000000..ca34179cf5b --- /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 00000000000..197cc8206d0 --- /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 00000000000..756eb07189a --- /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 00000000000..d038ed4157f --- /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 00000000000..6b9dbdb19b1 --- /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 00000000000..c0b26284cdf --- /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 00000000000..f1b76b0d155 --- /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 00000000000..4726d57ad24 --- /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 00000000000..e32557f1d95 --- /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 00000000000..26eaec4584d --- /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 00000000000..de84482cc63 --- /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 00000000000..6773465bbbf --- /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 00000000000..c6fb13c3605 --- /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 099e32c9d1b..15b6a681056 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 9a32626c66a..b916b794936 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 08985bff945..52e9c6a719f 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 6bfe170c67c..ea62c0bd7e4 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 90b0a7f475d..a100e2c0a49 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 00000000000..458a2929d0b --- /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 52e9c6a719f..3f58fefb389 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 29f26cc84e6..480ec31da7d 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -69,27 +69,32 @@ SERVICE_SCAN_SCHEMA = vol.Schema({ @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 @@ class ImageProcessingEntity(Entity): """ 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 @@ class ImageProcessingEntity(Entity): 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 @@ class ImageProcessingEntity(Entity): 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 b916b794936..25859020be4 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 3f58fefb389..34e3283abb8 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 ea62c0bd7e4..a50e10a871e 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 5d6e1453124..074d6a1054e 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -64,7 +64,7 @@ class XiaomiMiioDeviceScanner(DeviceScanner): 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 51d70d1f3b2..0ca60894f9b 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os 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 34e3283abb8..800b7e85c16 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 6f59da0755a..0a370d754ee 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -5,9 +5,9 @@ For more details about this component, please refer to the documentation at 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 @@ class DeconzBinarySensor(BinarySensorDevice): """ 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 @@ class DeconzBinarySensor(BinarySensorDevice): 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 4fa89f8cfd3..88174b9d612 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -22,7 +22,7 @@ from .const import ( 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 f7aa4c7a430..6deee322a15 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -11,3 +11,6 @@ DATA_DECONZ_UNSUB = 'deconz_dispatchers' 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 0db06622ad8..7c492fd496d 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -4,9 +4,9 @@ Support for deCONZ sensor. 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 @@ class DeconzSensor(Entity): """ 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 @@ class DeconzSensor(Entity): 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 800b7e85c16..b3bb6261cab 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 a50e10a871e..6942b16bc29 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 770287228a2..bc0764f3a35 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -11,7 +11,6 @@ from numbers import Number from functools import partial from copy import copy from datetime import timedelta -import math import voluptuous as vol @@ -55,8 +54,6 @@ DEFAULT_WINDOW_SIZE = 1 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_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ 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 @@ class RangeFilter(Filter): 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 @@ class RangeFilter(Filter): 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 6f233dafe08..0a6c959f243 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -10,6 +10,7 @@ import logging 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 @@ class PrometheusMetrics(object): '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 cf8b7dfad48..824e330d6a0 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -81,7 +81,11 @@ class TahomaCover(TahomaDevice, CoverDevice): 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 00000000000..79a65508287 --- /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 bc0764f3a35..261f6e2b510 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 3e909b7b21d..438366ae555 100644 --- a/homeassistant/components/sensor/fixer.py +++ b/homeassistant/components/sensor/fixer.py @@ -10,15 +10,14 @@ import logging 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 @@ ICON = 'mdi:currency-usd' 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 @@ class ExchangeRateSensor(Entity): """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 @@ class ExchangeRateSensor(Entity): 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 b3bb6261cab..b6677698772 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 3ae47ba5dee..32f8e15748d 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -25,9 +25,7 @@ _LOGGER = logging.getLogger(__name__) 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 b6677698772..c170a477f07 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 25859020be4..cb5f06f12ed 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 c170a477f07..26a0745a37f 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 6942b16bc29..01ac2b301e1 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 9716e46bc03..35566b0cbed 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 cb5f06f12ed..d74aadd3323 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 26a0745a37f..35e740082ea 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 01ac2b301e1..1715f95b5df 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 | 156 ++++++++++-------- requirements_all.txt | 2 +- 2 files changed, 89 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index 377686b6905..6df9f3c9974 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -5,24 +5,22 @@ For more details about this platform, please refer to the documentation at 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 @@ ATTR_VOIP_STATE = 'voip_state' 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 + 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 TileDeviceScanner(DeviceScanner): - """Define a device scanner for Tiles.""" +class TileScanner(object): + """Define an object to retrieve Tile data.""" - def __init__(self, hass, config, see): + def __init__(self, client, hass, async_see, types, show_inactive): """Initialize.""" - from pytile import Client + self._async_see = async_see + self._client = client + self._hass = hass + self._show_inactive = show_inactive + self._types = types - _LOGGER.debug('Received configuration data: %s', config) + async def async_init(self): + """Further initialize connection to the Tile servers.""" + from pytile.errors import TileError - # 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]) + try: + await self._client.async_init() + except TileError as err: + _LOGGER.error('Unable to set up Tile scanner: %s', err) + return False - if not save_json( - hass.config.path(CLIENT_UUID_CONFIG_FILE), - {'client_uuid': self._client.client_uuid}): - _LOGGER.error("Failed to save configuration file") + await self._async_update() - _LOGGER.debug('Client UUID: %s', self._client.client_uuid) - _LOGGER.debug('User UUID: %s', self._client.user_uuid) + async_track_time_interval( + self._hass, self._async_update, DEFAULT_SCAN_INTERVAL) - self._show_inactive = config.get(CONF_SHOW_INACTIVE) - self._types = config.get(CONF_MONITORED_VARIABLES) + return True - self.devices = {} - self.see = see + async def _async_update(self, now=None): + """Update info from Tile.""" + from pytile.errors import SessionExpiredError, TileError - track_utc_time_change( - hass, self._update_info, second=range(0, 60, 30)) + _LOGGER.debug('Updating Tile data') - self._update_info() + 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 - 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) - - 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 35e740082ea..9591925ac24 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 202c6ac594d..791de291b48 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -310,7 +310,7 @@ class YeelightLight(Light): 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 00000000000..fc4b18e26e4 --- /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 00000000000..78053e540f5 --- /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 a0198169b6d..e3c3a0cf5ca 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -12,18 +12,20 @@ import voluptuous as vol 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 @@ _MONITORED_CONDITIONS = list(_UTILISATION_MON_COND.keys()) + \ 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 setup_platform(hass, config, add_devices, discovery_info=None): 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 setup_platform(hass, config, add_devices, discovery_info=None): 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 @@ class SynoNasSensor(Entity): 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 9591925ac24..0e6e6e0fc21 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 | 153 ++++++++++-------- 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 | 59 ++++--- 7 files changed, 149 insertions(+), 102 deletions(-) diff --git a/homeassistant/auth.py b/homeassistant/auth.py index a4e8ee05943..e6760cd9096 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -79,7 +79,14 @@ class AuthProvider: 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 @@ class AuthProvider: 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 @@ class AuthManager: """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 @@ class AuthManager: """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 @@ class AuthManager: @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 @@ class AuthStore: 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 @@ class AuthStore: 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 + kwargs = { + 'name': name + } - 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) + 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 - 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 - - raise ValueError('We got credentials with ID but found no 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 @@ class AuthStore: 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 @@ class AuthStore: '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 0f7295a41e0..511999c52ab 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -236,18 +236,16 @@ class GrantTokenView(HomeAssistantView): 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 @@ class GrantTokenView(HomeAssistantView): '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 @@ class GrantTokenView(HomeAssistantView): 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 @@ class GrantTokenView(HomeAssistantView): 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 @@ class GrantTokenView(HomeAssistantView): 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 3377a60c45b..cb0bab4afed 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 7a8f17894aa..3a186a0454c 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 3a51cd3e059..ccb8f49ea97 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 8a1b934ab76..00e3ee88d16 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 5b545223c15..8096a081679 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) - with pytest.raises(ValueError): - await manager.async_create_refresh_token(user, 'bla') + 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_cannot_create_refresh_token_with_invalide_user(hass): - """Test that we cannot create refresh token with invalid client id.""" +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, []) - client = await manager.async_create_client('test') - user = MockUser(id='invalid-user') + user = MockUser().add_to_auth_manager(manager) + assert user.system_generated is False + with pytest.raises(ValueError): - await manager.async_create_refresh_token(user, client.id) + 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_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, []) + 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) + + 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 d74aadd3323..0b9c8edd411 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 0e6e6e0fc21..b1863df1e46 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 1715f95b5df..476b3d96c3d 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 838358fcfca..c11c83ab40e 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -183,9 +183,12 @@ class PollencomSensor(Entity): 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 fa58c9b0baa..475e43e55a4 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -16,7 +16,7 @@ from homeassistant.const import ( 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 18029691dc7..609887e9690 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -13,7 +13,10 @@ import homeassistant.helpers.config_validation as cv 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 @@ SENSOR_TYPES = { '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 @@ class ArloSensor(Entity): """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 @@ class ArloSensor(Entity): 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 @@ class ArloSensor(Entity): 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 b1863df1e46..1491e1dbff4 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 00000000000..d31490ab2af --- /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 40ffe498402..72a7db1ac7a 100644 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -9,8 +9,8 @@ import logging 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 @@ HMIP_OPEN = 'open' 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 bf96f1f746d..8cf47159c10 100644 --- a/homeassistant/components/climate/homematicip_cloud.py +++ b/homeassistant/components/climate/homematicip_cloud.py @@ -12,8 +12,8 @@ from homeassistant.components.climate import ( 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 @@ HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()} async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HomematicIP climate devices.""" + pass + + +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 - 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.groups: if isinstance(device, HeatingGroup): diff --git a/homeassistant/components/homematicip_cloud.py b/homeassistant/components/homematicip_cloud.py deleted file mode 100644 index 859841dfca6..00000000000 --- 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 00000000000..887a3a5780b --- /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 00000000000..3ff4e438f53 --- /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 00000000000..9e5356d914a --- /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 00000000000..c40e577ae4a --- /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 00000000000..94fe5f40be8 --- /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 00000000000..cb2925d1a70 --- /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 00000000000..a4e3e78e860 --- /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 00000000000..887a3a5780b --- /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 e433da44ae7..5984fb03657 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -9,8 +9,8 @@ import logging 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 @@ ATTR_PROFILE_MODE = 'profile_mode' 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 ccd1949cc3b..0596bc0b6cc 100644 --- a/homeassistant/components/sensor/homematicip_cloud.py +++ b/homeassistant/components/sensor/homematicip_cloud.py @@ -8,8 +8,8 @@ https://home-assistant.io/components/sensor.homematicip_cloud/ 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 @@ STATE_SABOTAGE = 'sabotage' 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 bab2abbad0d..b9ee8126ed3 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_setup(hass, config): 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 9123d46c87b..68884aaaa02 100644 --- a/homeassistant/components/switch/homematicip_cloud.py +++ b/homeassistant/components/switch/homematicip_cloud.py @@ -9,8 +9,8 @@ import logging 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 @@ ATTR_PROFILE_MODE = 'profile_mode' 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 be67ebd9cc3..2e5613057f1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -127,6 +127,7 @@ HANDLERS = Registry() FLOWS = [ 'cast', 'deconz', + 'homematicip_cloud', 'hue', 'nest', 'sonos', diff --git a/requirements_all.txt b/requirements_all.txt index 1491e1dbff4..c72e56821d6 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 476b3d96c3d..aabbdc44bea 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 7bf87c74de7..9a5b4dd1a43 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -56,6 +56,7 @@ TEST_REQUIREMENTS = ( '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 00000000000..1d89bd73183 --- /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 00000000000..1c2e54a1a5d --- /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 00000000000..5344773fde6 --- /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 00000000000..18537227247 --- /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 85895fdd751..21accdf84b3 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( 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 c72e56821d6..4ac29b5775f 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 0ca60894f9b..ecb31bdef86 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os 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 4ac29b5775f..b6c1bb7dc41 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 d6e92d5b8ff..c8d3be81468 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 aabbdc44bea..0f7e5a264ee 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 7a0918aab25..28b3a05e403 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 b089d3f89be..5b3c43ec8c8 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 92ef78f60f3..61eee99e721 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME _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 5cb7e283c99..bea02143d72 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -19,7 +19,7 @@ from homeassistant.const import ( 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 c13f622c5bf..1afea2c1607 100644 --- a/homeassistant/components/device_tracker/cisco_ios.py +++ b/homeassistant/components/device_tracker/cisco_ios.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, \ _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 c3c4a48bb82..228443fe22b 100644 --- a/homeassistant/components/device_tracker/unifi_direct.py +++ b/homeassistant/components/device_tracker/unifi_direct.py @@ -16,7 +16,7 @@ from homeassistant.const import ( 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 a47db7f633c..90638cd9dfc 100644 --- a/homeassistant/components/media_player/pandora.py +++ b/homeassistant/components/media_player/pandora.py @@ -22,7 +22,7 @@ from homeassistant.const import (STATE_OFF, STATE_PAUSED, STATE_PLAYING, 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 1b7d5685231..880b3604a86 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -57,7 +57,7 @@ VACUUM_SET_FAN_SPEED_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ 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 9e5efffdccb..153d00f92fc 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 32374b90135..66b17cf9bd9 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 b6c1bb7dc41..1949e52200b 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 0f7e5a264ee..2fb7153efb5 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 928d894c9d1..bbf10dd309d 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ REQUIRES = [ '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 9b4c0c69ac6..1c37c9049f3 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 @@ import homeassistant.components.mqtt as mqtt 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 f9ec83cc8be..cc57c801430 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 @@ VALID_CONFIG_WITHOUT_CATEGORIES = { } +# 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 e67d5de50d1..0296b8c2fba 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -11,6 +11,8 @@ from yarl import URL from aiohttp.client_exceptions import ClientResponseError +retype = type(re.compile('')) + class AiohttpClientMocker: """Mock Aiohttp client requests.""" @@ -40,7 +42,7 @@ class AiohttpClientMocker: 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 @@ class AiohttpClientMockResponse: 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 ca82c83d0fc..578a431febf 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 0b9c8edd411..ca886ec25f8 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 1949e52200b..c84f484cabd 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 2fb7153efb5..88a54841b8e 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 ca886ec25f8..a6fb8735a66 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -50,7 +50,7 @@ MANIFEST_JSON = { '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 5984fb03657..5c513113f90 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -17,7 +17,7 @@ DEPENDENCIES = ['homematicip_cloud'] _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 fc4b18e26e4..def5c53dd3f 100644 --- a/homeassistant/components/camera/push.py +++ b/homeassistant/components/camera/push.py @@ -29,6 +29,8 @@ DEFAULT_NAME = "Push Camera" 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class PushCamera(Camera): 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 879f6a61899..75e456f62bd 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -75,6 +75,7 @@ class EnOceanDongle: _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 @@ class EnOceanDongle: 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 @@ class EnOceanDongle: 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 abe197485d4..986744aeec1 100644 --- a/homeassistant/components/switch/enocean.py +++ b/homeassistant/components/switch/enocean.py @@ -18,10 +18,12 @@ _LOGGER = logging.getLogger(__name__) 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 @@ class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): self._light = None self._on_state = False self._on_state2 = False + self.channel = channel self.stype = "switch" @property @@ -61,7 +65,7 @@ class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): 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 @@ class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): 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 ff0e4d907b1..2b2b9eb5c28 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -12,19 +12,19 @@ import voluptuous as vol 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_VALID_ZONES = ['Zone2', 'Zone3'] 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 @@ class DenonDevice(MediaPlayerDevice): 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 @@ class DenonDevice(MediaPlayerDevice): 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 @@ class DenonDevice(MediaPlayerDevice): """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 @@ class DenonDevice(MediaPlayerDevice): """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 @@ class DenonDevice(MediaPlayerDevice): """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 c84f484cabd..42d5c84c868 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 ff2d4adf30d..9b7da71a293 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -270,11 +270,14 @@ class _AlexaInterface(object): """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 @@ class _AlexaThermostatController(_AlexaInterface): 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 c14a33dce01..b9fe2941463 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -5,12 +5,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.efergy/ """ import logging + +import requests import voluptuous as vol -from requests import RequestException, get - -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_CURRENCY +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,6 @@ CONF_UTC_OFFSET = 'utc_offset' 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 @@ class EfergySensor(Entity): """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 @@ class EfergySensor(Entity): 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 @@ class EfergySensor(Entity): """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 83309329a11..9a79ab5b81c 100644 --- a/tests/components/sensor/test_efergy.py +++ b/tests/components/sensor/test_efergy.py @@ -14,21 +14,20 @@ ONE_SENSOR_CONFIG = { '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 @@ class TestEfergySensor(unittest.TestCase): 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 @@ class TestEfergySensor(unittest.TestCase): """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 2b0a64fbae5..73fc9b549b6 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 8b2ccfff18a..41150a30e87 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 4033ad074a6..f1c1ce248be 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 e6760cd9096..ae191f24c61 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 @@ class AuthManager: """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 @@ class AuthManager: 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 @@ class AuthStore: """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 @@ class AuthStore: 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 @@ class AuthStore: 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 @@ class AuthStore: if data is None: self._users = {} - self._clients = {} return users = { @@ -618,12 +553,7 @@ class AuthStore: ) 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 @@ class AuthStore: 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 511999c52ab..c41b417576e 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -115,7 +115,8 @@ from homeassistant.helpers.data_entry_flow import ( 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 @@ class LoginFlowIndexView(FlowManagerIndexView): """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 @@ class LoginFlowResourceView(FlowManagerResourceView): 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 @@ class LoginFlowResourceView(FlowManagerResourceView): 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 @@ class GrantTokenView(HomeAssistantView): """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 @@ class GrantTokenView(HomeAssistantView): '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 @@ class GrantTokenView(HomeAssistantView): 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 @@ class GrantTokenView(HomeAssistantView): 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 @@ class GrantTokenView(HomeAssistantView): 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 122c3032188..00000000000 --- 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 00000000000..ef7f8a9b292 --- /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 a6fb8735a66..4304742021f 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 @@ class IndexView(HomeAssistantView): 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 ccb8f49ea97..98a3b0a6074 100644 --- a/tests/common.py +++ b/tests/common.py @@ -31,6 +31,8 @@ from homeassistant.util.async_ import ( _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 @@ class MockUser(auth.User): 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 21719c12569..ce94d1ecbfa 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 @@ BASE_CONFIG = [{ '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 65ad22efae2..00000000000 --- 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 00000000000..7bd720ddf70 --- /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 7cff04327b8..68a77d18d56 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 853c002ba46..28a924bb43a 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 ad39fba3997..50bd03d6ced 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 00e3ee88d16..843866cbfbd 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,7 +3,7 @@ import pytest 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 @@ def hass_ws_client(aiohttp_client): 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 8096a081679..3119c3d8d71 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,7 +6,8 @@ import pytest 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 4304742021f..3080177ba38 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 42d5c84c868..3fc5a235128 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 88a54841b8e..6f88c4b26ed 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 87893125e6f..279fb1e2694 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -4,7 +4,6 @@ Support for Google Calendar Search binary sensors. 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 203b1a94b7f..fdbc3382072 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -25,6 +25,7 @@ from homeassistant.util import convert, dt 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 3fc5a235128..482c4cbbd49 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 e9a7efeb64a..dfc66a412c3 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -66,7 +66,6 @@ class MikrotikScanner(DeviceScanner): 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 e86e7348d58..69d4905228a 100644 --- a/homeassistant/components/eufy.py +++ b/homeassistant/components/eufy.py @@ -49,7 +49,6 @@ EUFY_DISPATCH = { 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 6f0a8816eea..2e7370cb336 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 891525d3979..7320ea8d557 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 be0c0527f1b..06f054a03f7 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -20,7 +20,7 @@ from homeassistant.const import ( 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 482c4cbbd49..848364ed3f4 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 46c1a24caa0..d59331984b7 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -38,7 +38,8 @@ SUPPORTED_VOICES = ['Geraint', 'Gwyneth', 'Mads', 'Naja', 'Hans', 'Marlene', '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 a100e2c0a49..8fbfb72d930 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 00000000000..e55e15c68c1 --- /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 848364ed3f4..0e221c436cd 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 f556b62e935..c863f804513 100644 --- a/homeassistant/components/image_processing/facebox.py +++ b/homeassistant/components/image_processing/facebox.py @@ -10,20 +10,26 @@ import logging 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class FaceClassifyEntity(ImageProcessingFaceEntity): 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 @@ class FaceClassifyEntity(ImageProcessingFaceEntity): """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 1f1fa347dc9..0689c34c1a3 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 9449ebf5f71..86811f94db3 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 @@ from homeassistant.setup import async_setup_component 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 @@ PARSED_FACES = [{ATTR_NAME: 'John Lennon', 'height': 75, 'left': 63, 'top': 262, - 'width': 74}, - }] + 'width': 74}}] MATCHED_FACES = {'John Lennon': 58.12} @@ -58,16 +62,42 @@ VALID_CONFIG = { } +@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 @@ async def test_process_image(hass, mock_image): 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 c41b417576e..3e236876d6a 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -102,6 +102,7 @@ a limited expiration. "token_type": "Bearer" } """ +from datetime import timedelta import logging import uuid @@ -114,6 +115,7 @@ from homeassistant.helpers.data_entry_flow import ( 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 68a77d18d56..c5c46d55e39 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 3080177ba38..0fa9f90805d 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 @@ class IndexView(HomeAssistantView): # 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 @@ class IndexView(HomeAssistantView): 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 0e221c436cd..3f0d3f8314a 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 6f88c4b26ed..1daaa106e99 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 191e587feaf..bdc2c5990d9 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation at 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 @@ from homeassistant.const import ( 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 @@ CONF_STATION = 'station' 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 @@ SENSOR_TYPES = { '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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoData(object): 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 e55e15c68c1..ae400ca6385 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 37ae60e3494..75c85a4bfcf 100644 --- a/homeassistant/components/light/xiaomi_aqara.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -31,7 +31,7 @@ class XiaomiGatewayLight(XiaomiDevice, Light): """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 @@ class XiaomiGatewayLight(XiaomiDevice, Light): 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 @@ class XiaomiGatewayLight(XiaomiDevice, Light): @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 4fe4da5a942..6b161f86ab0 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -88,6 +88,8 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): 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 ae191f24c61..c84f5e83ef0 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -311,10 +311,7 @@ class AuthManager: 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 3119c3d8d71..a53c5aaec99 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 e84009301ab..4f2ea408e7f 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -23,7 +23,7 @@ DEPENDENCIES = ['ring'] _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 8fbfb72d930..73a79c2d87b 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 00000000000..4f69e76f954 --- /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 00000000000..7263871e249 --- /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 3f0d3f8314a..3b216db656d 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 1428bbd3e56..12de686d232 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv 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 3b216db656d..0690539bdee 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 34fdcb2c035..237a6d219f0 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -26,6 +26,12 @@ HOMEKIT_ACCESSORY_DISPATCH = { '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 setup(hass, config): 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 71d3f9d95d7..19aff97491e 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -46,7 +46,7 @@ MIN_SATURATION = 10 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 @@ class LimitlessLEDGroup(Light): @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 @@ class LimitlessLEDGroup(Light): 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 f81d2ef1037..84a72945a7e 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_setup(hass, config): 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 00000000000..893fa76c44b --- /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 c40e577ae4a..54b05c464b5 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -6,6 +6,7 @@ _LOGGER = logging.getLogger('homeassistant.components.homematicip_cloud') 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 5344773fde6..476bed368d7 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 a232d9295a4..2cc62dce38e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -27,7 +27,8 @@ def setup_auth(app, trusted_networks, use_auth, 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 5b3c43ec8c8..0a3d710810c 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 578a431febf..4ed68fddf37 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 c84f5e83ef0..00000000000 --- 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 00000000000..c5db65586b1 --- /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 00000000000..691e561f22f --- /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 00000000000..082d8966275 --- /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 00000000000..38e054dc7cf --- /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 00000000000..d6630383ff2 --- /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 c4d2021f6ce..fa6878da065 100644 --- a/homeassistant/auth_providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -6,14 +6,17 @@ import hmac 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 @@ class Data: if data is None: data = { - 'salt': auth.generate_secret(), + 'salt': generate_secret(), 'users': [] } @@ -112,8 +115,8 @@ class Data: 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 a8e8cd0cb0e..e06b16177a1 100644 --- a/homeassistant/auth_providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -5,9 +5,11 @@ import hmac 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 @@ USER_SCHEMA = vol.Schema({ }) -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 510cc4d0279..57c05e3bdc8 100644 --- a/homeassistant/auth_providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -9,15 +9,18 @@ import hmac 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 00000000000..402caae4618 --- /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 4705e7580ca..00000000000 --- 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 b3b2d05e933..3a804c50c74 100644 --- a/homeassistant/components/rachio.py +++ b/homeassistant/components/rachio.py @@ -10,7 +10,7 @@ import logging 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 52ff0e19c59..48632ccab83 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -13,6 +13,7 @@ import voluptuous as vol 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 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ 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 dacdc7b18e2..aa39e9f66df 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -5,7 +5,7 @@ import os 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 00000000000..48a99324b30 --- /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 1d9a29bf48b..98701ba2857 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 cb0bab4afed..8e8c9738756 100644 --- a/tests/auth_providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -4,8 +4,8 @@ import uuid 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 @@ from tests.common import mock_coro @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 3a186a0454c..007e37b90c4 100644 --- a/tests/auth_providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -4,13 +4,14 @@ from unittest.mock import Mock 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 a53c5aaec99..805369a6da8 100644 --- a/tests/test_auth.py +++ b/tests/auth/test_init.py @@ -5,6 +5,8 @@ from unittest.mock import Mock, patch 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 98a3b0a6074..b3da5e0d098 100644 --- a/tests/common.py +++ b/tests/common.py @@ -12,6 +12,7 @@ import threading 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 3e5eed4c924..19785958422 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -7,7 +7,7 @@ import pytest 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 e6aa7893f33..cd0524eb032 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch 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 7d3d2d2af88..496308598dc 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 0a71c2887b1..a190aea9fa8 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -28,9 +28,8 @@ ERROR_LOG_FILENAME = 'home-assistant.log' # 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 e2d02acc61c..eb2e8391221 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 4574437bac9..86594b74995 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -4,8 +4,6 @@ Register an iFrame front end panel. 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 @@ CONFIG_SCHEMA = vol.Schema({ }})}, 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 e0950172913..c7aa04910bd 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -17,7 +17,8 @@ import threading 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 @@ class HomeAssistant(object): 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 @@ class HomeAssistant(object): 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 962074ec3af..a68b489868d 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -80,11 +80,10 @@ class Store: 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 153d00f92fc..b22271d6eb5 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -16,14 +16,20 @@ import logging 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 @@ PATH_CUSTOM_COMPONENTS = 'custom_components' 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 1664653f2a7..5398cfde963 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 @@ async def _async_setup_component(hass: core.HomeAssistant, 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 d2138f4293c..a26f7014444 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 37b917baa2e..0f07a90e9bb 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -6,9 +6,11 @@ import re 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 get_age(date: dt.datetime) -> str: 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 0e53342b0ca..74feb779dcd 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -8,8 +8,6 @@ from homeassistant.exceptions import HomeAssistantError _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 ecef1087747..4cc0fff96b9 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -86,11 +86,11 @@ class UnitSystem(object): 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 @@ class UnitSystem(object): 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 0e7befd5e9e..298d52722a5 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -57,7 +57,7 @@ class SafeLineLoader(yaml.SafeLoader): 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 e329f835f84..4f258bc2b09 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 4abce180093..7633c820d2d 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 734f4b548b9..d08915b348b 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -411,6 +411,22 @@ class TestSecrets(unittest.TestCase): 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 4ed68fddf37..6e22f2a5e95 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 c8d3be81468..db53699379c 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 1daaa106e99..a4a8bd03b3f 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 bea02143d72..710a07f77d3 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -311,12 +311,11 @@ class SshConnection(_Connection): 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 @@ class TelnetConnection(_Connection): 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 c5db65586b1..fb35bd05c33 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -51,7 +51,7 @@ class AuthManager: 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 @@ class AuthManager: 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 @@ class AuthManager: 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 @@ class AuthManager: 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 @@ class AuthManager: 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 @@ class AuthManager: """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 @@ class AuthManager: """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 691e561f22f..ebd61140ac1 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 @@ class AuthStore: 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 @@ class AuthStore: 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 @@ class AuthStore: 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 d6630383ff2..3769248fc05 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 @@ class AuthProvider: # 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 fa6878da065..17a56bc5f42 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -7,6 +7,8 @@ import hmac 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 @@ from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS 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 @@ class Data: 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 @@ class Data: '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 @@ class HassAuthProvider(AuthProvider): '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 3e236876d6a..1ead4cacdf0 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -152,7 +152,7 @@ class AuthProvidersView(HomeAssistantView): '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 14550dab899..22354b51956 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -66,8 +66,8 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ 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 b907d4b4217..581d8fc3f7b 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -49,6 +49,10 @@ async def async_setup(hass, config): 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 00000000000..6f00b03dedb --- /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 00000000000..fca03ad8fa9 --- /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 2cc62dce38e..46d77214160 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 c26f68a2c29..6cd16909041 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -7,7 +7,7 @@ https://home-assistant.io/developers/websocket_api/ 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 @@ class ActiveConnection: 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 aa39e9f66df..fea523c4117 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 98701ba2857..08fb63a3c72 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 805369a6da8..f7187fd49fd 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 b3da5e0d098..b03d473e6f3 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 @@ import threading 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 @@ class MockUser(auth_models.User): 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 @@ def mock_storage(data=None): 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 00000000000..fe8f351955f --- /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 00000000000..fa4ab612bb1 --- /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 843866cbfbd..5f6a17a4101 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 @@ from tests.common import MockUser, CLIENT_ID @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 19785958422..31cba79a6c8 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 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 @@ -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 0a350b62fc1..28c47948666 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ if os.environ.get('UVLOOP') == '1': 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 cd0524eb032..1320be299b8 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -6,21 +6,26 @@ import pytest 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 0fa9f90805d..76182dea6f9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 0690539bdee..7ac82d93a56 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 a4a8bd03b3f..5967bd7c4dc 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 48632ccab83..2afa943ee50 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 @@ async def async_hass_config_yaml(hass): 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 c7aa04910bd..8b534bf1731 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 @@ class HomeAssistant(object): # 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 @@ class HomeAssistant(object): 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 @@ class HomeAssistant(object): 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 @@ class HomeAssistant(object): """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 b22271d6eb5..52e6b1e7703 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 b3e5f417618..ae932b7d955 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 5398cfde963..478320dca27 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -26,7 +26,7 @@ SLOW_SETUP_WARNING = 10 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 74feb779dcd..1029e58c118 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 00000000000..3970ea72d47 --- /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 6e22f2a5e95..fb36ac6511a 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 9abd96664f2..5d4cdcc17d4 100644 --- a/homeassistant/components/light/mystrom.py +++ b/homeassistant/components/light/mystrom.py @@ -155,7 +155,11 @@ class MyStromLight(Light): 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 ecb31bdef86..54e7eb01ae1 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os 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 7ac82d93a56..867f9d502b9 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 c1083cc1857..b77f9060b40 100644 --- a/tests/components/binary_sensor/test_trend.py +++ b/tests/components/binary_sensor/test_trend.py @@ -273,8 +273,7 @@ class TestTrendBinarySensor: 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 @@ class TestTrendBinarySensor: }) 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 0cbece6d1b0..956b407eeaa 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -168,8 +168,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): 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 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): @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 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): @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 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): 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 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): 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 78750e91f83..de7865517a8 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -31,8 +31,7 @@ class TestComponentsDeviceTrackerMQTT(unittest.TestCase): 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 43f4fc3bbf3..8ab6346f19b 100644 --- a/tests/components/device_tracker/test_mqtt_json.py +++ b/tests/components/device_tracker/test_mqtt_json.py @@ -41,8 +41,7 @@ class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): 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 ccfa59404a1..d1ede721142 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -45,8 +45,7 @@ class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): @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 49bcd8a73ec..7d6dd65e90a 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -177,8 +177,7 @@ class TestLightMQTT(unittest.TestCase): }) 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 @@ class TestLightMQTT(unittest.TestCase): 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 @@ class TestLightMQTT(unittest.TestCase): 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 @@ class TestLightMQTT(unittest.TestCase): 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 af560bff9c3..f16685b3575 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -115,8 +115,7 @@ class TestLightMQTTJSON(unittest.TestCase): """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 @@ class TestLightMQTTJSON(unittest.TestCase): }) 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 @@ class TestLightMQTTJSON(unittest.TestCase): 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 @@ class TestLightMQTTJSON(unittest.TestCase): 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 @@ class TestLightMQTTJSON(unittest.TestCase): '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 @@ class TestLightMQTTJSON(unittest.TestCase): 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 1440a73f98e..e1c3da50e7e 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -51,8 +51,7 @@ class TestLightMQTTTemplate(unittest.TestCase): """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 @@ class TestLightMQTTTemplate(unittest.TestCase): }) 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 @@ class TestLightMQTTTemplate(unittest.TestCase): 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 @@ class TestLightMQTTTemplate(unittest.TestCase): 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 @@ class TestLightMQTTTemplate(unittest.TestCase): 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 @@ class TestLightMQTTTemplate(unittest.TestCase): 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 a8b8a201217..774185c51c1 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -79,8 +79,7 @@ class TestDeviceSunLightTrigger(unittest.TestCase): 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 @@ class TestDeviceSunLightTrigger(unittest.TestCase): 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 5d909492380..70f7152e07f 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -428,8 +428,7 @@ class TestComponentHistory(unittest.TestCase): 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 6c71a263afa..a3a5273ed4e 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -542,8 +542,7 @@ class TestComponentLogbook(unittest.TestCase): 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 33154090286..540f8d91da9 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -168,8 +168,7 @@ class TestCheckConfig(unittest.TestCase): '.../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 @@ class TestCheckConfig(unittest.TestCase): 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 5c513113f90..9851248f7cc 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -7,7 +7,8 @@ https://home-assistant.io/components/light.homematicip_cloud/ 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 @@ class HomematicipLightMeasuring(HomematicipLight): 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 3bfa167f8ec..f468e8c25ef 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -324,9 +324,11 @@ class ZwaveColorLight(ZwaveDimmer): 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 4966b161360..62bcf834b98 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 12de686d232..6754db05f77 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -71,7 +71,7 @@ HM_DEVICE_TYPES = { '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 @@ HM_DEVICE_TYPES = { '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 @@ HM_ATTRIBUTE_SUPPORT = { '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 42b4829f64e..c357d1ccc04 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 @@ class InsteonPLMSwitchDevice(InsteonPLMEntity, SwitchDevice): 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 00000000000..d7691cea011 --- /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 7263871e249..c557774b5f1 100644 --- a/homeassistant/components/tuya.py +++ b/homeassistant/components/tuya.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import ( 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_FORCE_UPDATE = 'force_update' SERVICE_PULL_DEVICES = 'pull_devices' TUYA_TYPE_TO_HA = { - 'switch': 'switch' + 'light': 'light', + 'switch': 'switch', } CONFIG_SCHEMA = vol.Schema({ @@ -129,13 +130,18 @@ class TuyaDevice(Entity): """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 867f9d502b9..d688ef7adb1 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 72a7db1ac7a..6966f61129c 100644 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -21,8 +21,6 @@ ATTR_EVENT_DELAY = 'event_delay' 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 @@ class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): @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 3ff4e438f53..b9266322978 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -19,7 +19,7 @@ from .config_flow import configured_haps 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 9851248f7cc..617a7209a86 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 0596bc0b6cc..87021e9c7c5 100644 --- a/homeassistant/components/sensor/homematicip_cloud.py +++ b/homeassistant/components/sensor/homematicip_cloud.py @@ -24,14 +24,6 @@ ATTR_TEMPERATURE = 'temperature' 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 @@ class HomematicipAccesspointStatus(HomematicipGenericDevice): """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 @@ class HomematicipHeatingThermostat(HomematicipGenericDevice): @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 @@ class HomematicipHumiditySensor(HomematicipGenericDevice): """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 @@ class HomematicipTemperatureSensor(HomematicipGenericDevice): """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 d688ef7adb1..49950f3f2e3 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 5967bd7c4dc..49e79f6b962 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 fb35bd05c33..b05fca164a0 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -93,10 +93,15 @@ class AuthManager: 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 @@ class AuthManager: 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 @@ class AuthManager: 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 @@ class AuthManager: 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 @@ class AuthManager: 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 ebd61140ac1..8fd66d4bbb7 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -81,6 +81,16 @@ class AuthStore: 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 1ead4cacdf0..f9588093933 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -275,6 +275,12 @@ class GrantTokenView(HomeAssistantView): }, 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 46d77214160..2cc62dce38e 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 f7187fd49fd..3e3662c13c4 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 c5c46d55e39..59fc8714f77 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -10,7 +10,7 @@ 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): +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 28a924bb43a..13515db87fa 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 6ea90bcdb88..dc1688bae16 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 616144d2bc6..93e15b9cd5e 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -16,9 +16,7 @@ from homeassistant.const import CONF_NAME 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 49950f3f2e3..49e5691bb6c 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 b05fca164a0..9f342a50407 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -148,6 +148,8 @@ class AuthManager: 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 f9588093933..6518c2bcc1c 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -243,6 +243,7 @@ class GrantTokenView(HomeAssistantView): 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 @@ class GrantTokenView(HomeAssistantView): 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 3e3662c13c4..cad4bbdbd71 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 59fc8714f77..5f3a2d6478c 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 b9db9d4f99b..c5cd9a8c4fd 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -218,6 +218,9 @@ class FluxLight(Light): 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 @@ class FluxLight(Light): 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 19aff97491e..2263a865758 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -21,7 +21,7 @@ from homeassistant.util.color import ( 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 49e5691bb6c..a9835d0f3c3 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 76182dea6f9..61def9075f8 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 a9835d0f3c3..cde7060df74 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 49e79f6b962..08909b3bf6f 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 cb6858639f4..182367f3890 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 182367f3890..5b100414e48 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 5b100414e48..2ecd1d7b649 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 9f342a50407..cc2f244efb4 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -194,9 +194,14 @@ class AuthManager: 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 6518c2bcc1c..84287c2e425 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -113,6 +113,7 @@ from homeassistant import data_entry_flow 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 @@ from . import indieauth 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 _create_cred_store(): 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 61def9075f8..89233b6c518 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -257,7 +257,7 @@ async def async_setup(hass, config): 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 5f3a2d6478c..46b88e46b4d 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 17a56bc5f42..b359f67d77f 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 89233b6c518..958247cadc5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -29,7 +29,7 @@ from homeassistant.util.yaml import load_yaml 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 @@ class IndexView(HomeAssistantView): 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 00000000000..6dea5919f09 --- /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 00000000000..3aa106ac18c --- /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 00000000000..1a536a1bc43 --- /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 00000000000..62c6dc929a1 --- /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 00000000000..57a81a78da3 --- /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 00000000000..d6a4030190d --- /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 237a6d219f0..5e24fe82340 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -14,7 +14,7 @@ from homeassistant.components.discovery import SERVICE_HOMEKIT 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 cde7060df74..20419f4f5d6 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 958247cadc5..141da89f359 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 1a536a1bc43..17d83003c48 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -92,6 +92,7 @@ class UserOnboardingView(_BaseOnboardingView): 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 20419f4f5d6..76198d54bc6 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 08909b3bf6f..ad3877392c7 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 2ecd1d7b649..9466e94998b 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 141da89f359..5a3ac0d16b5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 76198d54bc6..db81024a84a 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 ad3877392c7..01f90202e8f 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 2afa943ee50..d9206d62250 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -20,7 +20,7 @@ from homeassistant.const import ( 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 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ 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 717a3f62ec9..435d3a00ec2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,7 @@ import unittest.mock as mock 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 @@ from homeassistant.const import ( 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 9466e94998b..fb3aed50449 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 5a3ac0d16b5..dc5d1d7bf7e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 db81024a84a..bf7042d8b9d 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 01f90202e8f..e1e1593b814 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 fca03ad8fa9..960e8f5e7b4 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -20,6 +20,13 @@ SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ 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 @@ def websocket_delete(hass, connection, msg): 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 fa4ab612bb1..cd2cbc44539 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 fc02009b7af..4f528cfcb51 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -6,21 +6,14 @@ import certifi 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 | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 6988e20fb5f..ce94a560dae 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/emulated_hue/ """ import logging +from aiohttp import web import voluptuous as vol from homeassistant import util @@ -13,7 +14,6 @@ from homeassistant.const import ( 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 - ) + app = web.Application() + app['hass'] = hass + handler = None + server = None - 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)) + 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 84287c2e425..435555c2e31 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 ce94a560dae..36ce1c392f9 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 c8eba41e66b..0cbee628a8a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -187,8 +187,7 @@ class HomeAssistantHTTP(object): 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 @@ class HomeAssistantHTTP(object): '{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 0a37f22867e..b01e68f701d 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 3de276564eb..23698af8101 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 @@ class HomeAssistantView(object): 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 @@ class HomeAssistantView(object): 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 46b88e46b4d..1d3719b8c66 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 1617f327d27..c99d273a458 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 27367b4173e..523d4943ba0 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -19,14 +19,14 @@ from homeassistant.components.http.cors import setup_cors 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 2b966daff6c..b5eed19eb61 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -23,7 +23,7 @@ async def get_client(aiohttp_client, validator): """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 fb3aed50449..e541d8e1954 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 dc5d1d7bf7e..68e88406ad6 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations 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 bf7042d8b9d..97c3d583580 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 e1e1593b814..3de2285eae9 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 3aa8e82911e..3066819638f 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -22,7 +22,7 @@ from .const import ( 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 97c3d583580..72dc74b0f66 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 cc2f244efb4..62c416a9883 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -124,6 +124,7 @@ class AuthManager: 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 3769248fc05..68cc1c7edd2 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -135,5 +135,9 @@ class AuthProvider: """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 b359f67d77f..d24110a4736 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -184,7 +184,8 @@ class HassAuthProvider(AuthProvider): 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 e06b16177a1..c86c8eb71f1 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -75,14 +75,16 @@ class ExampleAuthProvider(AuthProvider): 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 57c05e3bdc8..1f92fb60f13 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -70,7 +70,10 @@ class LegacyApiPasswordAuthProvider(AuthProvider): 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 fea523c4117..d141faa4c27 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 08fb63a3c72..9db6293d98a 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -4,6 +4,7 @@ from unittest.mock import Mock 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 8e8c9738756..b472e4c95df 100644 --- a/tests/auth/providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -4,7 +4,7 @@ import uuid 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 007e37b90c4..71642bd7a32 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 1d3719b8c66..807bf15854b 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 1320be299b8..f6c027150dd 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 bdc2c5990d9..54b095bb84b 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -51,7 +51,6 @@ SENSOR_TYPES = { '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 @@ class NetAtmoSensor(Entity): 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 fe8b7db84d1..e05f951322e 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 23698af8101..7823d674ab3 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -12,6 +12,7 @@ from aiohttp import web 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 @@ def request_handler_factory(view, handler): 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 6cd16909041..98e3057338a 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -26,7 +26,8 @@ from homeassistant.helpers.service import async_get_all_descriptions 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 @@ class ActiveConnection: 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 c5691cf3e2a..a6a07928113 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 test_ip_bans_file_creation(hass, aiohttp_client): 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 e541d8e1954..1627910f9bb 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 09a4fa3610d..669901f5b57 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -66,6 +66,8 @@ class TPLinkSmartBulb(Light): 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 @@ class TPLinkSmartBulb(Light): @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 @@ class TPLinkSmartBulb(Light): 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