From 2d5da3e9589d5d0c71668da2a1ed51f9f9d2fc60 Mon Sep 17 00:00:00 2001 From: Jack Minardi Date: Tue, 25 Apr 2017 00:32:31 -0400 Subject: [PATCH 001/135] Catch `KeyError`; Add `response.text` to error message --- homeassistant/components/device_tracker/tplink.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 8d476136d23..b1ad68af3aa 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -195,8 +195,9 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): self.sysauth = regex_result.group(1) _LOGGER.info(self.sysauth) return True - except ValueError: - _LOGGER.error("Couldn't fetch auth tokens!") + except (ValueError, KeyError) as e: + _LOGGER.error("Couldn't fetch auth tokens!" + "Response was: {}".format(response.text)) return False @Throttle(MIN_TIME_BETWEEN_SCANS) From 450fd7f2b5efb7408668f9713991a39fe782b0cd Mon Sep 17 00:00:00 2001 From: Jack Minardi Date: Tue, 25 Apr 2017 00:34:26 -0400 Subject: [PATCH 002/135] Log out of router admin interface after devices are recorded. --- .../components/device_tracker/tplink.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index b1ad68af3aa..770d5f22a4d 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -162,6 +162,7 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() + self._log_out() return self.last_results.keys() # pylint: disable=no-self-use @@ -251,6 +252,21 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): return False + def _log_out(self): + with self.lock: + _LOGGER.info("Logging out of router admin interface...") + + url = ('http://{}/cgi-bin/luci/;stok={}/admin/system?' + 'form=logout').format(self.host, self.stok) + referer = 'http://{}/webpages/index.html'.format(self.host) + + response = requests.post(url, + params={'operation': 'write'}, + headers={'referer': referer}, + cookies={'sysauth': self.sysauth}) + self.stok = '' + self.sysauth = '' + class Tplink4DeviceScanner(TplinkDeviceScanner): """This class queries an Archer C7 router with TP-Link firmware 150427.""" From 943861a8a3105d28c02707ae6dde2fd6d688342c Mon Sep 17 00:00:00 2001 From: Jack Minardi Date: Tue, 25 Apr 2017 00:45:59 -0400 Subject: [PATCH 003/135] Remove unused var --- homeassistant/components/device_tracker/tplink.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 770d5f22a4d..1aeabbea15c 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -260,10 +260,10 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): 'form=logout').format(self.host, self.stok) referer = 'http://{}/webpages/index.html'.format(self.host) - response = requests.post(url, - params={'operation': 'write'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}) + requests.post(url, + params={'operation': 'write'}, + headers={'referer': referer}, + cookies={'sysauth': self.sysauth}) self.stok = '' self.sysauth = '' From 8bf1c217386b3e12b745873f721adc99ec19a927 Mon Sep 17 00:00:00 2001 From: Jack Minardi Date: Tue, 25 Apr 2017 00:48:23 -0400 Subject: [PATCH 004/135] Add space --- homeassistant/components/device_tracker/tplink.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 1aeabbea15c..d80213dd549 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -198,7 +198,7 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): return True except (ValueError, KeyError) as e: _LOGGER.error("Couldn't fetch auth tokens!" - "Response was: {}".format(response.text)) + " Response was: {}".format(response.text)) return False @Throttle(MIN_TIME_BETWEEN_SCANS) From b6827ce57a92955dcb9886b82f23a0af43a0e9e0 Mon Sep 17 00:00:00 2001 From: Jack Minardi Date: Sun, 30 Apr 2017 21:02:03 -0400 Subject: [PATCH 005/135] Use throwaray variable name --- homeassistant/components/device_tracker/tplink.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index d80213dd549..560babe3d61 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -196,7 +196,7 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): self.sysauth = regex_result.group(1) _LOGGER.info(self.sysauth) return True - except (ValueError, KeyError) as e: + except (ValueError, KeyError) as _: _LOGGER.error("Couldn't fetch auth tokens!" " Response was: {}".format(response.text)) return False From dd7690f26552f94b3333caabf02e50c9d88e3f48 Mon Sep 17 00:00:00 2001 From: Jack Minardi Date: Sun, 30 Apr 2017 21:31:55 -0400 Subject: [PATCH 006/135] Use % formatting --- homeassistant/components/device_tracker/tplink.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 560babe3d61..032e5db8247 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -198,7 +198,7 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): return True except (ValueError, KeyError) as _: _LOGGER.error("Couldn't fetch auth tokens!" - " Response was: {}".format(response.text)) + " Response was: %s" % response.text) return False @Throttle(MIN_TIME_BETWEEN_SCANS) From bc0559813c7ccb091444ebdcdc0cfa082e1aaf9f Mon Sep 17 00:00:00 2001 From: Jack Minardi Date: Sun, 30 Apr 2017 22:26:16 -0400 Subject: [PATCH 007/135] Dont add two strings inside logger call --- homeassistant/components/device_tracker/tplink.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 032e5db8247..c456d5d7adc 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -197,8 +197,8 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): _LOGGER.info(self.sysauth) return True except (ValueError, KeyError) as _: - _LOGGER.error("Couldn't fetch auth tokens!" - " Response was: %s" % response.text) + m = "Couldn't fetch auth tokens! Response was: %s" % response.text + _LOGGER.error(m) return False @Throttle(MIN_TIME_BETWEEN_SCANS) From 7a24e210ae74dd8c7a9cecdf6b270bb8646b144f Mon Sep 17 00:00:00 2001 From: Jack Minardi Date: Mon, 1 May 2017 09:31:23 -0400 Subject: [PATCH 008/135] Try again to pass string to error msg --- homeassistant/components/device_tracker/tplink.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index c456d5d7adc..f38e7d07b45 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -197,8 +197,8 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): _LOGGER.info(self.sysauth) return True except (ValueError, KeyError) as _: - m = "Couldn't fetch auth tokens! Response was: %s" % response.text - _LOGGER.error(m) + _LOGGER.error("Couldn't fetch auth tokens! Response was: %s", + response.text) return False @Throttle(MIN_TIME_BETWEEN_SCANS) From dc716cd971b471afce89666d1c0b7781ebe7e5e6 Mon Sep 17 00:00:00 2001 From: Brian Cribbs Date: Mon, 1 May 2017 13:12:43 -0400 Subject: [PATCH 009/135] repairing functionality for non-zero based ranges --- homeassistant/components/cover/mqtt.py | 52 +++++++++++++++--- tests/components/cover/test_mqtt.py | 73 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 2115434232c..d95a88a2fa8 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -40,6 +40,7 @@ CONF_TILT_OPEN_POSITION = 'tilt_opened_value' CONF_TILT_MIN = 'tilt_min' CONF_TILT_MAX = 'tilt_max' CONF_TILT_STATE_OPTIMISTIC = 'tilt_optimistic' +CONF_TILT_INVERT_STATE = "tilt_invert_state" DEFAULT_NAME = 'MQTT Cover' DEFAULT_PAYLOAD_OPEN = 'OPEN' @@ -52,6 +53,7 @@ DEFAULT_TILT_OPEN_POSITION = 100 DEFAULT_TILT_MIN = 0 DEFAULT_TILT_MAX = 100 DEFAULT_TILT_OPTIMISTIC = False +DEFAULT_TILT_INVERT_STATE = False TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | SUPPORT_SET_TILT_POSITION) @@ -76,6 +78,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ default=DEFAULT_TILT_MAX): int, vol.Optional(CONF_TILT_STATE_OPTIMISTIC, default=DEFAULT_TILT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT_INVERT_STATE, + default=DEFAULT_TILT_INVERT_STATE): cv.boolean, }) @@ -106,6 +110,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_TILT_MIN), config.get(CONF_TILT_MAX), config.get(CONF_TILT_STATE_OPTIMISTIC), + config.get(CONF_TILT_INVERT_STATE), )]) @@ -116,7 +121,8 @@ class MqttCover(CoverDevice): tilt_status_topic, qos, retain, state_open, state_closed, payload_open, payload_close, payload_stop, optimistic, value_template, tilt_open_position, - tilt_closed_position, tilt_min, tilt_max, tilt_optimistic): + tilt_closed_position, tilt_min, tilt_max, tilt_optimistic, + tilt_invert): """Initialize the cover.""" self._position = None self._state = None @@ -140,6 +146,7 @@ class MqttCover(CoverDevice): self._tilt_min = tilt_min self._tilt_max = tilt_max self._tilt_optimistic = tilt_optimistic + self._tilt_invert = tilt_invert @asyncio.coroutine def async_added_to_hass(self): @@ -152,8 +159,8 @@ class MqttCover(CoverDevice): """The tilt was updated.""" if (payload.isnumeric() and self._tilt_min <= int(payload) <= self._tilt_max): - tilt_range = self._tilt_max - self._tilt_min - level = round(float(payload) / tilt_range * 100.0) + + level = self.find_percentage_in_range(float(payload)) self._tilt_value = level self.hass.async_add_job(self.async_update_ha_state()) @@ -280,7 +287,8 @@ class MqttCover(CoverDevice): 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, self._retain) + self._tilt_open_position, self._qos, + self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_open_position self.hass.async_add_job(self.async_update_ha_state()) @@ -289,7 +297,8 @@ class MqttCover(CoverDevice): 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, self._retain) + self._tilt_closed_position, self._qos, + self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_closed_position self.hass.async_add_job(self.async_update_ha_state()) @@ -303,9 +312,36 @@ class MqttCover(CoverDevice): position = float(kwargs[ATTR_TILT_POSITION]) # The position needs to be between min and max - tilt_range = self._tilt_max - self._tilt_min - percentage = position / 100.0 - level = round(tilt_range * percentage) + level = self.find_in_range_from_percent(position) mqtt.async_publish(self.hass, self._tilt_command_topic, level, self._qos, self._retain) + + def find_percentage_in_range(self, position): + """Find the 0-100% value within the specified range.""" + # the range of motion as defined by the min max values + tilt_range = self._tilt_max - self._tilt_min + # offset to be zero based + offset_position = position - self._tilt_min + # the percentage value within the range + position_percentage = float(offset_position) / tilt_range * 100.0 + if self._tilt_invert: + return 100 - position_percentage + else: + return position_percentage + + def find_in_range_from_percent(self, percentage): + """Find the adjusted value for 0-100% within the specified range.""" + # if the range is 80-180 and the percentage is 90 + # this method would determine the value to send on the topic + # by offsetting the max and min, getting the percentage value and + # returning the offset + offset = self._tilt_min + tilt_range = self._tilt_max - self._tilt_min + + position = round(tilt_range * (percentage / 100.0)) + position += offset + + if self._tilt_invert: + position = self._tilt_max - position + offset + return position diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index b2dcf8e175d..e685a51f56c 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -4,6 +4,7 @@ import unittest from homeassistant.setup import setup_component from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN import homeassistant.components.cover as cover +from homeassistant.components.cover.mqtt import MqttCover from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) @@ -450,3 +451,75 @@ class TestCoverMQTT(unittest.TestCase): self.assertEqual(('tilt-command-topic', 25, 0, False), self.mock_publish.mock_calls[-2][1]) + + def test_find_percentage_in_range_defaults(self): + """Test find percentage in range with default range.""" + mqtt_cover = MqttCover( + 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, + 100, 0, 0, 100, False, False) + + self.assertEqual(44, mqtt_cover.find_percentage_in_range(44)) + + def test_find_percentage_in_range_altered(self): + """Test find percentage in range with altered range.""" + mqtt_cover = MqttCover( + 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, + 180, 80, 80, 180, False, False) + + self.assertEqual(40, mqtt_cover.find_percentage_in_range(120)) + + def test_find_percentage_in_range_defaults_inverted(self): + """Test find percentage in range with default range but inverted.""" + mqtt_cover = MqttCover( + 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, + 100, 0, 0, 100, False, True) + + self.assertEqual(56, mqtt_cover.find_percentage_in_range(44)) + + def test_find_percentage_in_range_altered_inverted(self): + """Test find percentage in range with altered range and inverted.""" + mqtt_cover = MqttCover( + 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, + 180, 80, 80, 180, False, True) + + self.assertEqual(60, mqtt_cover.find_percentage_in_range(120)) + + def test_find_in_range_defaults(self): + """Test find in range with default range.""" + mqtt_cover = MqttCover( + 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, + 100, 0, 0, 100, False, False) + + self.assertEqual(44, mqtt_cover.find_in_range_from_percent(44)) + + def test_find_in_range_altered(self): + """Test find in range with altered range.""" + mqtt_cover = MqttCover( + 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, + 180, 80, 80, 180, False, False) + + self.assertEqual(120, mqtt_cover.find_in_range_from_percent(40)) + + def test_find_in_range_defaults_inverted(self): + """Test find in range with default range but inverted.""" + mqtt_cover = MqttCover( + 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, + 100, 0, 0, 100, False, True) + + self.assertEqual(44, mqtt_cover.find_in_range_from_percent(56)) + + def test_find_in_range_altered_inverted(self): + """Test find in range with altered range and inverted.""" + mqtt_cover = MqttCover( + 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, + 180, 80, 80, 180, False, True) + + self.assertEqual(120, mqtt_cover.find_in_range_from_percent(60)) From 098e28534bd7c76687117c53902ff8a8dfb4723f Mon Sep 17 00:00:00 2001 From: Brian Cribbs Date: Mon, 1 May 2017 13:34:34 -0400 Subject: [PATCH 010/135] fixing documentation --- homeassistant/components/cover/mqtt.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index d95a88a2fa8..eca34062058 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -331,11 +331,14 @@ class MqttCover(CoverDevice): return position_percentage def find_in_range_from_percent(self, percentage): - """Find the adjusted value for 0-100% within the specified range.""" - # if the range is 80-180 and the percentage is 90 - # this method would determine the value to send on the topic - # by offsetting the max and min, getting the percentage value and - # returning the offset + """ + Find the adjusted value for 0-100% within the specified range. + + if the range is 80-180 and the percentage is 90 + this method would determine the value to send on the topic + by offsetting the max and min, getting the percentage value and + returning the offset + """ offset = self._tilt_min tilt_range = self._tilt_max - self._tilt_min From 1b2c83145cae3df81d5901c7bce8eb456e0ea0e5 Mon Sep 17 00:00:00 2001 From: Brian Cribbs Date: Tue, 2 May 2017 15:41:45 -0400 Subject: [PATCH 011/135] fixing nits --- homeassistant/components/cover/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index eca34062058..c5d6a3fbef4 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -40,7 +40,7 @@ CONF_TILT_OPEN_POSITION = 'tilt_opened_value' CONF_TILT_MIN = 'tilt_min' CONF_TILT_MAX = 'tilt_max' CONF_TILT_STATE_OPTIMISTIC = 'tilt_optimistic' -CONF_TILT_INVERT_STATE = "tilt_invert_state" +CONF_TILT_INVERT_STATE = 'tilt_invert_state' DEFAULT_NAME = 'MQTT Cover' DEFAULT_PAYLOAD_OPEN = 'OPEN' From 71d909483ca1627149dd5764af7e75ecb2f53896 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 26 Apr 2017 22:44:33 +0200 Subject: [PATCH 012/135] LIFX: refresh state after stopping an effect This clears the internal cache in case polling picked up the state as set by an effect. For example, aborting an effect by selecting a new brightness could keep a color set by the effect. --- homeassistant/components/light/lifx/effects.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index 07b97d03a12..2c054d49e1a 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -205,6 +205,7 @@ class LIFXEffect(object): light.device.set_color(light.effect_data.color) yield from asyncio.sleep(0.5) light.effect_data = None + yield from light.refresh_state() self.lights.remove(light) def from_poweroff_hsbk(self, light, **kwargs): From 99e34539b9dd08291033228e4405b91676356065 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 2 May 2017 23:30:07 +0200 Subject: [PATCH 013/135] LIFX: fix color restore after running effects State restoration takes up to a second because bulbs can be slow to react. During this time an effect could keep running, overwriting the state that we were trying to restore. Now the effect forgets the light immediately and it thus avoids further changes while the restored state settles. --- .../components/light/lifx/effects.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index 2c054d49e1a..ae9bd626fca 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -196,18 +196,19 @@ class LIFXEffect(object): @asyncio.coroutine def async_restore(self, light): """Restore to the original state (if we are still running).""" - if light.effect_data: - if light.effect_data.effect == self: - if light.device and not light.effect_data.power: - light.device.set_power(False) - yield from asyncio.sleep(0.5) - if light.device: - light.device.set_color(light.effect_data.color) - yield from asyncio.sleep(0.5) - light.effect_data = None - yield from light.refresh_state() + if light in self.lights: self.lights.remove(light) + if light.effect_data and light.effect_data.effect == self: + if light.device and not light.effect_data.power: + light.device.set_power(False) + yield from asyncio.sleep(0.5) + if light.device: + light.device.set_color(light.effect_data.color) + yield from asyncio.sleep(0.5) + light.effect_data = None + yield from light.refresh_state() + def from_poweroff_hsbk(self, light, **kwargs): """Return the color when starting from a powered off state.""" return None From 8233f086cd7904cdf11c7c229dd6f1f499837da5 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 26 Apr 2017 22:47:32 +0200 Subject: [PATCH 014/135] LIFX: Use 3500K as neutral white This does not really matter because the colorloop uses saturated colors (without much white). Anyway, just copy the 3500K that the LIFX app uses. --- homeassistant/components/light/lifx/effects.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index ae9bd626fca..1259d463e0e 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -31,6 +31,8 @@ ATTR_CHANGE = 'change' WAVEFORM_SINE = 1 WAVEFORM_PULSE = 4 +NEUTRAL_WHITE = 3500 + LIFX_EFFECT_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(ATTR_POWER_ON, default=True): cv.boolean, @@ -314,7 +316,7 @@ class LIFXEffectColorloop(LIFXEffect): int(65535/359*lhue), int(random.uniform(0.8, 1.0)*65535), brightness, - 4000, + NEUTRAL_WHITE, ] light.device.set_color(hsbk, None, transition) @@ -326,7 +328,7 @@ class LIFXEffectColorloop(LIFXEffect): def from_poweroff_hsbk(self, light, **kwargs): """Start from a random hue.""" - return [random.randint(0, 65535), 65535, 0, 4000] + return [random.randint(0, 65535), 65535, 0, NEUTRAL_WHITE] class LIFXEffectStop(LIFXEffect): From ec490070ca70c4be13f959a3c1f6fceee0c24132 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 26 Apr 2017 22:51:33 +0200 Subject: [PATCH 015/135] LIFX: Move random hue initial color to the LIFXEffect base class It's a reasonable default for several light effects. --- homeassistant/components/light/lifx/effects.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index 1259d463e0e..2dc56443723 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -213,7 +213,7 @@ class LIFXEffect(object): def from_poweroff_hsbk(self, light, **kwargs): """Return the color when starting from a powered off state.""" - return None + return [random.randint(0, 65535), 65535, 0, NEUTRAL_WHITE] class LIFXEffectBreathe(LIFXEffect): @@ -326,10 +326,6 @@ class LIFXEffectColorloop(LIFXEffect): yield from asyncio.sleep(period) - def from_poweroff_hsbk(self, light, **kwargs): - """Start from a random hue.""" - return [random.randint(0, 65535), 65535, 0, NEUTRAL_WHITE] - class LIFXEffectStop(LIFXEffect): """A no-op effect, but starting it will stop an existing effect.""" From 193270c4fbe5baeb196cf42eeb4116f0116ecaa4 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 29 Apr 2017 18:27:22 +0200 Subject: [PATCH 016/135] LIFX: Update aiolifx requirement This update silences some warnings (frawau/aiolifx#7). --- homeassistant/components/light/lifx/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/lifx/__init__.py b/homeassistant/components/light/lifx/__init__.py index f1b20f904d2..22e7ee04fcc 100644 --- a/homeassistant/components/light/lifx/__init__.py +++ b/homeassistant/components/light/lifx/__init__.py @@ -32,7 +32,7 @@ from . import effects as lifx_effects _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.4.5'] +REQUIREMENTS = ['aiolifx==0.4.6'] UDP_BROADCAST_PORT = 56700 diff --git a/requirements_all.txt b/requirements_all.txt index 358db1d3802..16f29438937 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -48,7 +48,7 @@ aiodns==1.1.1 aiohttp_cors==0.5.3 # homeassistant.components.light.lifx -aiolifx==0.4.5 +aiolifx==0.4.6 # homeassistant.components.alarmdecoder alarmdecoder==0.12.1.0 From 494a776959141c11eb0a0b3f36f167380cd92ee0 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 29 Apr 2017 22:24:18 +0200 Subject: [PATCH 017/135] LIFX: avoid warnings about already running updates Forcing a refresh will log a warning if the periodic async_update happens to be running already. So let's do the refresh locally and remove the force_refresh. --- homeassistant/components/light/lifx/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/lifx/__init__.py b/homeassistant/components/light/lifx/__init__.py index 22e7ee04fcc..01038814f51 100644 --- a/homeassistant/components/light/lifx/__init__.py +++ b/homeassistant/components/light/lifx/__init__.py @@ -263,17 +263,19 @@ class LIFXLight(Light): """Return the list of supported effects.""" return lifx_effects.effect_list() - @callback + @asyncio.coroutine def update_after_transition(self, now): """Request new status after completion of the last transition.""" self.postponed_update = None - self.hass.async_add_job(self.async_update_ha_state(force_refresh=True)) + yield from self.refresh_state() + yield from self.async_update_ha_state() - @callback + @asyncio.coroutine def unblock_updates(self, now): """Allow async_update after the new state has settled on the bulb.""" self.blocker = None - self.hass.async_add_job(self.async_update_ha_state(force_refresh=True)) + yield from self.refresh_state() + yield from self.async_update_ha_state() def update_later(self, when): """Block immediate update requests and schedule one for later.""" From 78a3f259d6eac2e080ebab3daf59a7e78f99c77e Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 3 May 2017 21:26:04 +0200 Subject: [PATCH 018/135] LIFX: handle unavailable lights gracefully Recent aiolifx allow sending messages to unregistered devices (as a no-op). This is handy because bulbs can disappear anytime we yield and constantly testing for availability is both error-prone and annoying. So keep the aiolifx device around until a new one registers on the same mac_addr. --- .../components/light/lifx/__init__.py | 19 +++++++------ .../components/light/lifx/effects.py | 27 +++++++++---------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/light/lifx/__init__.py b/homeassistant/components/light/lifx/__init__.py index 01038814f51..f13934011e9 100644 --- a/homeassistant/components/light/lifx/__init__.py +++ b/homeassistant/components/light/lifx/__init__.py @@ -93,6 +93,7 @@ class LIFXManager(object): if device.mac_addr in self.entities: entity = self.entities[device.mac_addr] entity.device = device + entity.registered = True _LOGGER.debug("%s register AGAIN", entity.who) self.hass.async_add_job(entity.async_update_ha_state()) else: @@ -118,7 +119,7 @@ class LIFXManager(object): if device.mac_addr in self.entities: entity = self.entities[device.mac_addr] _LOGGER.debug("%s unregister", entity.who) - entity.device = None + entity.registered = False self.hass.async_add_job(entity.async_update_ha_state()) @@ -172,6 +173,7 @@ class LIFXLight(Light): def __init__(self, device): """Initialize the light.""" self.device = device + self.registered = True self.product = device.product self.blocker = None self.effect_data = None @@ -183,7 +185,7 @@ class LIFXLight(Light): @property def available(self): """Return the availability of the device.""" - return self.device is not None + return self.registered @property def name(self): @@ -345,7 +347,7 @@ class LIFXLight(Light): def async_update(self): """Update bulb status (if it is available).""" _LOGGER.debug("%s async_update", self.who) - if self.available and self.blocker is None: + if self.blocker is None: yield from self.refresh_state() @asyncio.coroutine @@ -357,11 +359,12 @@ class LIFXLight(Light): @asyncio.coroutine def refresh_state(self): """Ask the device about its current state and update our copy.""" - msg = yield from AwaitAioLIFX(self).wait(self.device.get_color) - if msg is not None: - self.set_power(self.device.power_level) - self.set_color(*self.device.color) - self._name = self.device.label + if self.available: + msg = yield from AwaitAioLIFX(self).wait(self.device.get_color) + if msg is not None: + self.set_power(self.device.power_level) + self.set_color(*self.device.color) + self._name = self.device.label def find_hsbk(self, **kwargs): """Find the desired color from a number of possible inputs.""" diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index 2dc56443723..a15360df33e 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -176,18 +176,16 @@ class LIFXEffect(object): def async_setup(self, **kwargs): """Prepare all lights for the effect.""" for light in self.lights: + # Remember the current state (as far as we know it) yield from light.refresh_state() - if not light.device: - self.lights.remove(light) - else: - light.effect_data = LIFXEffectData( - self, light.is_on, light.device.color) + light.effect_data = LIFXEffectData( + self, light.is_on, light.device.color) - # Temporarily turn on power for the effect to be visible - if kwargs[ATTR_POWER_ON] and not light.is_on: - hsbk = self.from_poweroff_hsbk(light, **kwargs) - light.device.set_color(hsbk) - light.device.set_power(True) + # Temporarily turn on power for the effect to be visible + if kwargs[ATTR_POWER_ON] and not light.is_on: + hsbk = self.from_poweroff_hsbk(light, **kwargs) + light.device.set_color(hsbk) + light.device.set_power(True) # pylint: disable=no-self-use @asyncio.coroutine @@ -202,12 +200,13 @@ class LIFXEffect(object): self.lights.remove(light) if light.effect_data and light.effect_data.effect == self: - if light.device and not light.effect_data.power: + if not light.effect_data.power: light.device.set_power(False) yield from asyncio.sleep(0.5) - if light.device: - light.device.set_color(light.effect_data.color) - yield from asyncio.sleep(0.5) + + light.device.set_color(light.effect_data.color) + yield from asyncio.sleep(0.5) + light.effect_data = None yield from light.refresh_state() From 629bf3eefd829cd55570c1fdde930f024f895fe6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 4 May 2017 21:38:28 -0700 Subject: [PATCH 019/135] Update frontend --- homeassistant/components/frontend/version.py | 6 +++--- .../frontend/www_static/frontend.html | 5 +++-- .../frontend/www_static/frontend.html.gz | Bin 140429 -> 140627 bytes .../www_static/home-assistant-polymer | 2 +- .../components/frontend/www_static/mdi.html | 2 +- .../frontend/www_static/mdi.html.gz | Bin 197577 -> 198311 bytes .../www_static/panels/ha-panel-hassio.html | 4 ++-- .../www_static/panels/ha-panel-hassio.html.gz | Bin 7449 -> 7451 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2518 -> 2513 bytes 10 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 23437de3924..943074beb40 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,8 +3,8 @@ FINGERPRINTS = { "compatibility.js": "83d9c77748dafa9db49ae77d7f3d8fb0", "core.js": "5d08475f03adb5969bd31855d5ca0cfd", - "frontend.html": "094c2015c8291c767b8933428d92076f", - "mdi.html": "1cc8593d3684f7f6f3b3854403216f77", + "frontend.html": "5999c8fac69c503b846672cae75a12b0", + "mdi.html": "f407a5a57addbe93817ee1b244d33fbe", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", "panels/ha-panel-config.html": "59d9eb28758b497a4d9b2428f978b9b1", "panels/ha-panel-dev-event.html": "2db9c218065ef0f61d8d08db8093cad2", @@ -12,7 +12,7 @@ FINGERPRINTS = { "panels/ha-panel-dev-service.html": "415552027cb083badeff5f16080410ed", "panels/ha-panel-dev-state.html": "d70314913b8923d750932367b1099750", "panels/ha-panel-dev-template.html": "567fbf86735e1b891e40c2f4060fec9b", - "panels/ha-panel-hassio.html": "0aa1523357326cb40e2242dce9b2c0d6", + "panels/ha-panel-hassio.html": "333f86e5f516b31e52365e412deb7fdc", "panels/ha-panel-history.html": "89062c48c76206cad1cec14ddbb1cbb1", "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab", "panels/ha-panel-logbook.html": "6dd6a16f52117318b202e60f98400163", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index fa6cbcd1717..1fd4bda0275 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,6 +8,7 @@ window.hassUtil.DEFAULT_ICON = 'mdi:bookmark'; window.hassUtil.OFF_STATES = ['off', 'closed', 'unlocked']; window.hassUtil.DOMAINS_WITH_CARD = [ + 'binary_sensor', 'climate', 'cover', 'configurator', @@ -20,7 +21,7 @@ window.hassUtil.DOMAINS_WITH_CARD = [ ]; window.hassUtil.DOMAINS_WITH_MORE_INFO = [ - 'alarm_control_panel', 'automation', 'camera', 'climate', 'configurator', + 'alarm_control_panel', 'automation', 'binary_sensor', 'camera', 'climate', 'configurator', 'cover', 'fan', 'group', 'light', 'lock', 'media_player', 'script', 'sun', 'updater', ]; @@ -440,7 +441,7 @@ window.hassUtil.isComponentLoaded = function (hass, component) { window.hassUtil.computeLocationName = function (hass) { return hass.config.core.location_name; -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz index 719b9c608f185cedf81b026331d45d7403fd9af8..c7689872442293fb47f9500bae9c7482f0077000 100644 GIT binary patch delta 2296 zcmVI-5ENABzYGYyk|h2R;;kQN;Jwb~5q!vBVRkm_vkr27kp6 z#Ofh=qGV?dO+CvyhTVJA56Rc6k?#^$24HVhal$N_UM%h2=R2~Xdad1FYWihrN$T>#ATBQ+Lt94*TfI=;L#B|U)5!eNS!|-)ENMxg zD(fkOPm*XMH0!>DM4sT#I0ut|%+d(m>%%8$FeIS9Sgt_YbwbGR{nrgTVN^Pu>c+T% zDP2{yZ@Xl)EjxFXn}if3Rrpvu^zpIxWsQ8P8qSHfvQB=Z>g(eTiHNng4{p3W>A z=0*Q@c_Eq!1>U!%;5E@$@m}BN>#TvGFhlD-t$E65Z)Z0zxGw$2IRN&5eLg+Md8do; zu8_*V+y2?RaCj)}ZZ=}j+pT(+qy_vWet~{{_CR#{9$#sW^;`!bC}LoEJ!W9QdQBB4 zAax56U6U`FqigcYLsXh*+w~BNG2pHf_X2C>;6)3i?RKg%Z8x|$Y1zK=C=cXDyw|f(^noFkWQ8my5%JxqDcI}K&TQTk{2{$m& z`FzIvIe>l4pLY1%dWr;wRwS?aXTT*tk9Hcpy2R@;uGv$67}v~hT>DtgjK!<-yk#&m zy(2cYlZ?MUqjzN`OF-kS8z$CoE~qvQe#|E#^EB_59qaLvv7<3L$wV5sIE5RNmoNFj zSw4|f7vvI;&2n}xWHOn=5?#L4y=uQ!@_W*wZI@9Pias7gNmz~>7*A3KB*sq`*MVhU z{{aJS+%|uImpLD_LF_q+dYxzUWU(%ZbF)llOO#LCc-N=Fp}8|B5a_Vz*O{03DsSumxfE7c`anlSKak#k_V(s$u6vMRJ|LQX;3Y^*r=smf}(CsQ_}o-5u2!t3Hs>2W@Yx&Ux?dT;qh??YNCf?{^zjI2_b^AC=zw zKB}g2o=g?(-d}jw7C{Fa$Z#GHSpK<#d2zGU;qFh>M%3KIs+HZtLFiR2WR> zB}&6?8d0hRcORw6nn|6BUz(A*rmOLzY@=d-=1Q|#FzYbs%w#cH@+@(5jqi!$=&Kn> z8S{5X7aQyyV^EMspIw)7e^@6e-p`P%;rgJUKrvEURHGq#%_&e%DupV_a{XF?jT*0M zp-e6FvU0bVzUBOwb^Zmzc)L@#%i-G{(?2qXuDngfj)9X@KG3H9<`i?ay&c!dbRhYE z`99mpnzt@iZs#s?HQ`SZh;P2w&_>yl$%}%UP~FZVIuP)>oO%1h>1uLusUwF)=nAXf z=_fvwnU*eM=Q;2&@VeDx)`A70GhT`F4NbOZE}^Y`GR0d$mUmwpt7Z=C9}}aC-!?NZ z^WEFh?)^O){nC^1z8BX~vJ573HoyvhCvd*|24BXpz0t1!7U}=#y9=ff(dUVA1LiI`IozvL{o%+xy=$OWGR#+`SY+D%6_6++X~27zuN$5+MOZ`&QW+}E^7Np_ z{0t+Lf|lQ^t&zLgn104~3-HEJ07O26!7D3XCf1Pe2WlPf+=o!_ew;I7LgK-UKUDf& z{>0GER8#(Yg(68(S36b(+#2_PE`_XP>zmKnCP$60)%kragwuG5#`~dyD7q^7$DL8uyV?7n;1NG(z2gxc0kPv59u6^o&&wHko9n+F z*8Owf`%-a)!4;4E%m!cKu%Qk0&E9+O=nyt+X<#s_Dg?t zXyoFTa~|s6KV-z8rnZNS&t_I$=?iCpQ6LVgIpdsi*Z?g zKTOfY0QqsOwg&R{rkrYLdD&Ke*B+w7Yn*3|+9o=`h2Pcl0P65>LcP(T1#BSq_Z>P< zXydTi;{wT+u;s*9h~n#x?*%q&uH~#pn+KRPqE7lXRcLFaccit`twVP+HfgMt;TegG SDNDLaPyZJH4VrF!g#Z9V6QX(m delta 2294 zcmVm`v#azDr;kfW1}43A5ySvFvwS6U>YMQkhu?N%di! zW&B?Gy;qSt==B<&@5qDdwRU@{>6fb|smlw4xV(4_Z5>H$^+I_MnL>_EBlAn=v59W8 zq$Po>tfveh` z-*=lldlwFmf8EVS40^j&kCC*1pTrf=@5~;EPT%9x%(0%}AY?-f4DZAY3|OzJ)dZxj z^rLI?B@=T^UU`TX6K%U*KQRW}bn~AV26fS!>lg0}dGF_}B z^O96c%!Owkn(tCPrLeIXzQ3e3IlX>;NXLlNHI= zbkN**(Xjy%e>oyO-8A7~ICTl+W0-FA0DsOz^`SoH_I%qFgRldsZ=!bzA^Ize7< z#zzi+D#q_*d6q$1=Ba$m20?tAD&E+VLH|GfR8QI5l@Q7j_t@iq+I`|^_GxEEXgdQ{ z-=;G1!JjaBwBC+?YIzxTmD(3*rD4`E4UEh`x1&_pc6Z`vh%6fu!*+L4Q9f*ULx@Im zYJ^1e9!fuY1=dDisABea)Wn6Yx*rvKnygh#NqwQT)%%#79VxDzVQDL@eI?-rCOV(b zcs~cQkNMM1oLldYz|e~1HUA8_ejIi*CMTK5{T4@WWAgGPKP}5A zvg(3d;#pbFE`v-alUSn5x4KvD*Ghg*PPFX?3PaJyV<-trYy;y-s({4!$>KV&lPjEzi0G%#+uq)Pe9d(a63hogvk$!ah}nCE^e%hd zLO%yX{svv80k-t^9TIipV;!Tz%M&6I(a-W*>`ZSR3rN`Wuz@vPmnowza`4^sMzOx)P?&)s{ESRn?Zpaiqp|*&2Ki0Vas;IhK^v`fvbjLCAm%ccv%Z;I1u-xY6 z8QPU;`P>;{nS*cQ7xmlRDh}sd`*1mAit?ZNewLSK4jeTb>Go>AX2< z*i9o!wczffG+8sLGx19^GS_r9ew1xg%v@=IRtshwCY_lqCQF_rj;`@NaU6X$11V$v z?&xBJy<-dt^60ZWQtl7yB*jY^k~Lf(6ci{%N{ebVWKTE+>K&y}MOm(2E3i@H2`!YV zWnNb9_R_Z;8MDs6U>I+A>UKGNyJPxC#?Y0wsY1Nvm89}jHtmO|n5*sWxI?A`$lZ#6oIV?g~Sp7~v z@u|#QbP+qxfro+DttPV;EC`+P9-MDzvORMNZS9jO9uTrT_S#rAb6EeF7+w6JnR$cn z-j;UnkI?8hos37lxQ>!#FqyLfRycuw^W8W29FFZ_cKruP|4$!VFpZc-p|tes+`uv3 zr+>gB><@T(-B{m;s8Rj0{vLh0%1>d1i zdz{X(y_|EyB&bUQSoM>5zQ8OLZx$e83xWsgZ0k0rbn0uk32kj-N-`v|_aT6PJ?^`+ zYlPUWl~-n)F#@RTXSB3h8jP-&L;1~ukq z7?~8b{8nv^+|9=HGqziRH--Wr^6d*=S@ANlhI~Iz>v-orgnI1boEZ}m4`%#z()aRr zg?46_@*gP_d6Bx>u`1x!xOXXkWF1@I6wWp|YJ9EE?_(jH#`81Q$7&q)s7&M88Am+@ z)A?A8BGXj${qv!JOUO;v5<2WX(^bK&OzXI82RA-sta}dwvCn&^dV@RJ&7SSZfk~RN zt?-Z`whwUgl5{Okub)HFRmnf@jI!R%-v927_zmkFFYgG59dGY&hGT~*kMHRH;sL3~F4k$OA3FkaqN*2QJJ@SF<1*P!Fs-s*`cp$A z7r&hIQ1|{JBmN$>J!E_?gDvI5G$zvcfmlEC^TuN|c)3pgP_X(G9UdO!^1xY)%kraP ziY5lgk7KnpkXJV4RLdJ>w(`67`W)WfJZsc8(fKX>uAT=_hkq05jRq}X1G&HN(0M`| zhs_=rNVbG6C&oe)Uw3>Yuwip8XFb|Hz?>0v(yysPTPwXIt(|Tix|^{{W33F&NZdtP Q(p7r;zk*1?8FGaH0GK0?AOHXW diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 244c0a6b13d..79fc54a2fc6 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","43b9f99469029c48e429731761b03abd"],["/frontend/panels/dev-event-2db9c218065ef0f61d8d08db8093cad2.html","b5b751e49b1bba55f633ae0d7a92677d"],["/frontend/panels/dev-info-61610e015a411cfc84edd2c4d489e71d.html","6568377ee31cbd78fedc003b317f7faf"],["/frontend/panels/dev-service-415552027cb083badeff5f16080410ed.html","a4b1ec9bfa5bc3529af7783ae56cb55c"],["/frontend/panels/dev-state-d70314913b8923d750932367b1099750.html","c61b5b1461959aac106400e122993e9e"],["/frontend/panels/dev-template-567fbf86735e1b891e40c2f4060fec9b.html","d2853ecf45de1dbadf49fe99a7424ef3"],["/frontend/panels/map-31c592c239636f91e07c7ac232a5ebc4.html","182580419ce2c935ae6ec65502b6db96"],["/static/compatibility-83d9c77748dafa9db49ae77d7f3d8fb0.js","5f05c83be2b028d577962f9625904806"],["/static/core-5d08475f03adb5969bd31855d5ca0cfd.js","1cd99ba798bfcff9768c9d2bb2f58a7c"],["/static/frontend-094c2015c8291c767b8933428d92076f.html","9f6ff2fa80d6e34106c533b941ad0141"],["/static/mdi-1cc8593d3684f7f6f3b3854403216f77.html","eac41ec8397af607a07bc174e3c2475f"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","32b5a9b7ada86304bec6b43d3f2194f0"]],cacheName="sw-precache-v3--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var n=new URL(e);return"/"===n.pathname.slice(-1)&&(n.pathname+=t),n.toString()},cleanResponse=function(e){return e.redirected?("body"in e?Promise.resolve(e.body):e.blob()).then(function(t){return new Response(t,{headers:e.headers,status:e.status,statusText:e.statusText})}):Promise.resolve(e)},createCacheKey=function(e,t,n,a){var c=new URL(e);return a&&c.pathname.match(a)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(n)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var n=new URL(t).pathname;return e.some(function(e){return n.match(e)})},stripIgnoredUrlParameters=function(e,t){var n=new URL(e);return n.search=n.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),n.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],n=e[1],a=new URL(t,self.location),c=createCacheKey(a,hashParamName,n,!1);return[a.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(n){if(!t.has(n)){var a=new Request(n,{credentials:"same-origin"});return fetch(a).then(function(t){if(!t.ok)throw new Error("Request for "+n+" returned a response with status "+t.status);return cleanResponse(t).then(function(t){return e.put(n,t)})})}}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(n){return Promise.all(n.map(function(n){if(!t.has(n.url))return e.delete(n)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,n=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(n);t||(n=addDirectoryIndex(n,"index.html"),t=urlsToCacheKeys.has(n));!t&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(n=new URL("/",self.location).toString(),t=urlsToCacheKeys.has(n)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(n)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url)&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var n,a;for(n=0;nRRMwlCb**gK)T0jUbMs871?V?z)%g(IwP4q6J%Z>H+;)Bt*#*@8cEQ01OR+lr zV%=8v!TtHAHQbC|fyEj8Y-dZ|*wU0rxQCyvp|;ONRY{>f`4HOR&Xg^@Sa*xv?49V> zUD;?Dgj!gEPy2ErHy2tle>{&mP8+K?4D^yx_gT>RA2%3yIm2+v)Syq z{0Pbp=nb!esDgTK7Z(?sPgPT+C)Ua~?QX%v#eulcfK?0Mn}}68>CL;17Is;&iQ3rU zg?CG^i$-60FP^`8?S%^v8rts4q5Jn??B99Mn%W|k!295yXz98R3izp-7yiaSS9$IH$njydDe7RpnH%EH}~=Sxy-cREKVe zRbtXOi;dgDR+UZ)MnRdBX&@T6Zc=W5bCIV>3WlCmg=uAsXfvLugz}7L1{K7)$^||r zf`L@&Rs$&#=5XW+62&PKkb%k=ixZh4k+MO91E^FJ)ht&ykY!cc+i^iPS9zYNg%(C| zEmJN4iIW?u3nSy`V}}ZuiJ3yTK_cU%&@9h+mKgkFJWi3Mj}){B4$?KvaUB&}GM;g% zDJdA!Oo><-J;)_Wb1p^B3u%-wJkJWnbs}YASRrzCAa{$=eW}YaQL12^YMK=(@;Wof@PegjOcRnJ z54VCrs1#XRP@W5u#kq)csYspzRf*elFL-M;8r@CWMmDyYyzGC*KQ@()tToO?nsC&L zM&cd}A*tXjO%fr5CNj?Gw?zF?&KGtgE0@g3qA-X78b?xUkkJXjI7*U)BD;~Z&~7{!W=#RRF5OQdo7O~YQod|iolBTXo3a?UlwEM*wsnGq7Rjx013 zDbjpjTImYI#E5G*7!#g+#=mG)&ruF?ocA72knx$wAt_?qp#&ZiLUMLiPK5t(eVitg zNiA~J3T|7-1m$RrT*@#+57sZm96GEtt^Tk?Xv7DM`&(!~$oa+^LDQrlnkZ6GrYS8% zmL_Nlr!izih<#}{7_b<5O^xZj8)NKY=TO!|7|=vA!DTMAC^8zS=)O#*$U~D5o|@Qu z|32)C%x_`|Gq1ZDZ>!GunvTcbrQbniq88>QR;a#&Jw2LCo3;{ljuzN0?+5HA;^|dly{cZ`DXYc-(~9gC=aN(zoH=wtawT zGYXym)^1`LY@T1kXSl>czQ?%8|)09?tIrZOE}pFZ}!%=RH19JRzLRy)dvjy zW9&m|10u8SV8cHWL>)GL)<>j*^G*Bao0;c2cT-lV`P7Ft!al9NJ;A6an2n*suKr#8 zKIr@Zv${SuWxZ83jM215F|iQcqQ_te{SSMw?}d26=w3HlYIQvtzoNFop7h872c4Zc z1H&4B2!{HD=fXa5RL#&GBR^(`1op+DO~~OVWQ+bgv1fd!-ronow3mmuq(993;z(5^ zrWm&vI~7*beaj%a5v6@o+p_Ze9dr@qtlHltzw@W5<1^gZnBPVzs`r0rTX7pyN>!!n1n^gs%Wpu{GvyE`w8Hz_ zPp`c`8Mgx~qTwXG0TE;SY%cX5*5DSt)(-?zJA3Sff%sF4rKp}AbqUB93~n3@-Et6~ z6A(-eeUFbnEm#%+1YJ`)?H}~!KdG1=>6}6917rL z`E%$?^zk}$KI~{W9g@T*LA+kTs8^{U(t%wFJGv3w2tKd+-f2{JBV>4o8;iVws;al( zL=JFGbW^!-Yis}eXU)3O^}oKiBZoIU=;#yDcbzwKsdTX5YV_Un?+@|DOtQJs*-iJ% zKTrMDx?7yc5Yyp)2;GN;Y;*I}xfl!g6T^ufc+uSaws+0tW3?`Av*f@Zv?%5jWYz?#t-iARMlW| zKbjs;J2C0pvw`#EyTGm5%ekBM)id`8Oq3gJZZPzl8}HP$XWy}fVv1D3po^#VU$$-J zw7$FykkkUZks7-Zrw_A%%MPYd>&`X#VoP`t^>cnO#T8pKT&TV6RR_Bw_xO!(zn*0e boq^~bI^(Nuz^{8RV0Hg5e3-=mV-^4aUv%DK literal 2518 zcmV;{2`Tm;iwFqBD+pNv19N3^c4=c}Uw3bEYh`jSYI6XkSZj0Jx)J>=3a8g0Yl&$$K*qro55otF`-D6gzUJE|9clCB}wXt_l|f&Q-{WST3x}^(<*?Do*S4-4|JOAN zX^Q`Ja~IBUz#C{r&bhhud{L|Gi+{X({VK4?spe=!AGAx}j8+XuiyYLTi}~!+CuidL zP7({I8B0J>C94-6|bOdCkigdU}HXxlwdL1QI5Q5S&t-#xaLbWt>H6mOY@SsB}F}NlHQp3FQeP zQp-F6r6QTABxjHjwNaDOG>@|kASP0%EZ3l97{&s3q%+O+X*Df0mxY9JLMWwC7-dp~ zc`Pt4T2oEZFb@+nVXKKJf`DW~b1Gy^BgS=><+vxBNo(m2(qAk1XO z@j2oYgiJOXNFGt^M2BB*G|HG|N~TY5b!sOc12^6g21@glm%Fx|l0L zS;~ZpNlvMvl7~{OUM`V}F~KvI3oW%~S(;0xA|WD8bDqgPxf>6SLzc)0K`Zl!5t(6d za~8)58pfh9OZCPrOs85$n&+WPAx4;`lE#>r3E?Uv3E7jrRK=J`nNt?4IL#Bpb*d5J zIZcu9d!!hBuwW{}2ZtTVz8~EEq0nleOaPw_!rFkYiPcR`CtvV zpd*S%lE+lVan92uLQ_~ALP|K_m3D;*i9Xg*s`(2ZH#g{O1+N<%V>x?hs@z^1a^M*Gl(O{2!$+6L5Z<~FZ{Ju8JFO$zcSL(0W@oj&;eqJf~KD#3^w(;BU zSIumn{qXh$HpFSqYq0-eJFfyPGK&hO2Q9W*aL2?uJ9F=*&u7N-k%83jx}NHKx4tZa zS8t(3C$%s;%BdQRgEa}%6W6hQ*(q@P$(yEL7Oj2V*5xH2;d=K;nAsy? z8x7!GJ&F1uc?3iSw2)kal6M$_))#Dcer&If(!f7!`>$Hx;!`@Y^ltL?n;61DAf z(gOpm>&(m==vVkdFxBrO=XZsptb2Bh_?Ybj*yZ~+A%-83jrTwC9pioZ`PTEMojj~1 z-C^d11yzMU#k|GbDY2XGS_Z)tFU*I^6s6nkpbfBQRqnR@6}R%^Q((*gMwqj}^&yAuHnbo;GXeZ&~{@U;v469BNs z)k_4{J_34dZjQz$Lo)q8kaFToUme9q^-%Eb5#RH+V*8^je)C5hCCkDx0sQ5~@+Yq9 zkh1S5w8Ht@v-eI{jGG=6L4Oh+oQS!7vX;7cYj6t>@d0mYX7{7e1Al6;6;-nXmVkJ{ z9=ScM`oUMl4_m9sAHzZO z%T?Wcg9drkk0q}PZDrXLK4!7^=RYAK+uM0ZNw&4mp4|7V-)=QKxeedgD~&$+)JIIc z{~eHh1H3PP4qb~rT!+DjE$pU!kr)cZ`vr_TmFlh>m<2bZE8dRa`>NwkqoN%l!du)} z;Pg#W1Ap1u)ux;54iup8gI zzmsK+#r5<0(STR0$31|X=4@u09OC&XaE2J From 61196b1c83dbc4087ab4e8f0c009d7a908689fb9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 4 May 2017 21:41:32 -0700 Subject: [PATCH 020/135] Version bump to 0.45.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5ab322fab6c..c0ec2202a25 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 = 44 +MINOR_VERSION = 45 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 526abdd329fbe3945fd8e269f9102c3297e5d67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 5 May 2017 08:50:53 +0200 Subject: [PATCH 021/135] Add hass to rfxtrx object (#6844) --- homeassistant/components/cover/rfxtrx.py | 4 ++-- homeassistant/components/light/rfxtrx.py | 4 ++-- homeassistant/components/rfxtrx.py | 6 ++++-- homeassistant/components/switch/rfxtrx.py | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index 0e28d3ef701..f599ea3ede1 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the RFXtrx cover.""" import RFXtrx as rfxtrxmod - covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) + covers = rfxtrx.get_devices_from_config(config, RfxtrxCover, hass) add_devices_callback(covers) def cover_update(event): @@ -26,7 +26,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): not event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) + new_device = rfxtrx.get_new_device(event, config, RfxtrxCover, hass) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index 9248b0131f1..f831d6c04ce 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the RFXtrx platform.""" import RFXtrx as rfxtrxmod - lights = rfxtrx.get_devices_from_config(config, RfxtrxLight) + lights = rfxtrx.get_devices_from_config(config, RfxtrxLight, hass) add_devices(lights) def light_update(event): @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): not event.device.known_to_be_dimmable: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxLight) + new_device = rfxtrx.get_new_device(event, config, RfxtrxLight, hass) if new_device: add_devices([new_device]) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index b7f016d1029..3c3f1e00f68 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -191,7 +191,7 @@ def get_rfx_object(packetid): return obj -def get_devices_from_config(config, device): +def get_devices_from_config(config, device, hass): """Read rfxtrx configuration.""" signal_repetitions = config[CONF_SIGNAL_REPETITIONS] @@ -209,12 +209,13 @@ def get_devices_from_config(config, device): new_device = device(entity_info[ATTR_NAME], event, datas, signal_repetitions) + new_device.hass = hass RFX_DEVICES[device_id] = new_device devices.append(new_device) return devices -def get_new_device(event, config, device): +def get_new_device(event, config, device, hass): """Add entity if not exist and the automatic_add is True.""" device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: @@ -235,6 +236,7 @@ def get_new_device(event, config, device): signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, signal_repetitions) + new_device.hass = hass RFX_DEVICES[device_id] = new_device return new_device diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 1361d22de18..36044f5f168 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): import RFXtrx as rfxtrxmod # Add switch from config file - switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch) + switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch, hass) add_devices_callback(switches) def switch_update(event): @@ -31,7 +31,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch) + new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch, hass) if new_device: add_devices_callback([new_device]) From 92411cdc1898aa9e7eb403dbcd7102a08cfedc76 Mon Sep 17 00:00:00 2001 From: florincosta Date: Fri, 5 May 2017 10:02:47 +0300 Subject: [PATCH 022/135] Add new raspihats component (#7392) * Add new raspihats component * added raspihats to COMMENT_REQUIREMENTS in gen_requirements_all.py * disabled pylint import errors * using hass.data for storing i2c-hats manager --- .coveragerc | 2 + homeassistant/components/raspihats.py | 249 ++++++++++++++++++++++++++ requirements_all.txt | 3 + script/gen_requirements_all.py | 1 + 4 files changed, 255 insertions(+) create mode 100644 homeassistant/components/raspihats.py diff --git a/.coveragerc b/.coveragerc index 9656508982a..bbc40156cb5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -83,6 +83,8 @@ omit = homeassistant/components/qwikswitch.py homeassistant/components/*/qwikswitch.py + homeassistant/components/raspihats.py + homeassistant/components/rfxtrx.py homeassistant/components/*/rfxtrx.py diff --git a/homeassistant/components/raspihats.py b/homeassistant/components/raspihats.py new file mode 100644 index 00000000000..3ab433f4b91 --- /dev/null +++ b/homeassistant/components/raspihats.py @@ -0,0 +1,249 @@ +""" +Support for controlling raspihats boards. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/raspihats/ +""" +import logging +import threading +import time + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +) + +REQUIREMENTS = ['raspihats==2.2.1'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'raspihats' + +CONF_I2C_HATS = 'i2c_hats' +CONF_BOARD = 'board' +CONF_ADDRESS = 'address' +CONF_CHANNELS = 'channels' +CONF_INDEX = 'index' +CONF_INVERT_LOGIC = 'invert_logic' +CONF_INITIAL_STATE = 'initial_state' + +I2C_HAT_NAMES = [ + 'Di16', 'Rly10', 'Di6Rly6', + 'DI16ac', 'DQ10rly', 'DQ16oc', 'DI6acDQ6rly' +] + +I2C_HATS_MANAGER = 'I2CH_MNG' + + +# pylint: disable=unused-argument +def setup(hass, config): + """Setup the raspihats component.""" + hass.data[I2C_HATS_MANAGER] = I2CHatsManager() + + def start_i2c_hats_keep_alive(event): + """Start I2C-HATs keep alive.""" + hass.data[I2C_HATS_MANAGER].start_keep_alive() + + def stop_i2c_hats_keep_alive(event): + """Stop I2C-HATs keep alive.""" + hass.data[I2C_HATS_MANAGER].stop_keep_alive() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_i2c_hats_keep_alive) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_i2c_hats_keep_alive) + return True + + +def log_message(source, *parts): + """Build log message.""" + message = source.__class__.__name__ + for part in parts: + message += ": " + str(part) + return message + + +class I2CHatsException(Exception): + """I2C-HATs exception.""" + + +class I2CHatsDIScanner(object): + """Scan Digital Inputs and fire callbacks.""" + + _DIGITAL_INPUTS = "di" + _OLD_VALUE = "old_value" + _CALLBACKS = "callbacks" + + def setup(self, i2c_hat): + """Setup I2C-HAT instance for digital inputs scanner.""" + if hasattr(i2c_hat, self._DIGITAL_INPUTS): + digital_inputs = getattr(i2c_hat, self._DIGITAL_INPUTS) + old_value = None + # add old value attribute + setattr(digital_inputs, self._OLD_VALUE, old_value) + # add callbacks dict attribute {channel: callback} + setattr(digital_inputs, self._CALLBACKS, {}) + + def register_callback(self, i2c_hat, channel, callback): + """Register edge callback.""" + if hasattr(i2c_hat, self._DIGITAL_INPUTS): + digital_inputs = getattr(i2c_hat, self._DIGITAL_INPUTS) + callbacks = getattr(digital_inputs, self._CALLBACKS) + callbacks[channel] = callback + setattr(digital_inputs, self._CALLBACKS, callbacks) + + def scan(self, i2c_hat): + """Scan I2C-HATs digital inputs and fire callbacks.""" + if hasattr(i2c_hat, self._DIGITAL_INPUTS): + digital_inputs = getattr(i2c_hat, self._DIGITAL_INPUTS) + callbacks = getattr(digital_inputs, self._CALLBACKS) + old_value = getattr(digital_inputs, self._OLD_VALUE) + value = digital_inputs.value # i2c data transfer + if old_value is not None and value != old_value: + for channel in range(0, len(digital_inputs.channels)): + state = (value >> channel) & 0x01 + old_state = (old_value >> channel) & 0x01 + if state != old_state: + callback = callbacks.get(channel, None) + if callback is not None: + callback(state) + setattr(digital_inputs, self._OLD_VALUE, value) + + +class I2CHatsManager(threading.Thread): + """Manages all I2C-HATs instances.""" + + _EXCEPTION = "exception" + _CALLBACKS = "callbacks" + + def __init__(self): + """Init I2C-HATs Manager.""" + threading.Thread.__init__(self) + self._lock = threading.Lock() + self._i2c_hats = {} + self._run = False + self._di_scanner = I2CHatsDIScanner() + + def register_board(self, board, address): + """Register I2C-HAT.""" + with self._lock: + i2c_hat = self._i2c_hats.get(address) + if i2c_hat is None: + # pylint: disable=import-error + import raspihats.i2c_hats as module + constructor = getattr(module, board) + i2c_hat = constructor(address) + setattr(i2c_hat, self._CALLBACKS, {}) + + # Setting exception attribute will trigger online callbacks + # when keep alive thread starts. + setattr(i2c_hat, self._EXCEPTION, None) + + self._di_scanner.setup(i2c_hat) + self._i2c_hats[address] = i2c_hat + status_word = i2c_hat.status # read status_word to reset bits + _LOGGER.info( + log_message(self, i2c_hat, "registered", status_word) + ) + + def run(self): + """Keep alive for I2C-HATs.""" + # pylint: disable=import-error + from raspihats.i2c_hats import ResponseException + + _LOGGER.info( + log_message(self, "starting") + ) + while self._run: + with self._lock: + for i2c_hat in list(self._i2c_hats.values()): + try: + self._di_scanner.scan(i2c_hat) + self._read_status(i2c_hat) + + if hasattr(i2c_hat, self._EXCEPTION): + if getattr(i2c_hat, self._EXCEPTION) is not None: + _LOGGER.warning( + log_message(self, i2c_hat, "online again") + ) + delattr(i2c_hat, self._EXCEPTION) + # trigger online callbacks + callbacks = getattr(i2c_hat, self._CALLBACKS) + for callback in list(callbacks.values()): + callback() + except ResponseException as ex: + if not hasattr(i2c_hat, self._EXCEPTION): + _LOGGER.error( + log_message(self, i2c_hat, ex) + ) + setattr(i2c_hat, self._EXCEPTION, ex) + time.sleep(0.05) + _LOGGER.info( + log_message(self, "exiting") + ) + + def _read_status(self, i2c_hat): + """Read I2C-HATs status.""" + status_word = i2c_hat.status + if status_word.value != 0x00: + _LOGGER.error( + log_message(self, i2c_hat, status_word) + ) + + def start_keep_alive(self): + """Start keep alive mechanism.""" + self._run = True + threading.Thread.start(self) + + def stop_keep_alive(self): + """Stop keep alive mechanism.""" + self._run = False + self.join() + + def register_di_callback(self, address, channel, callback): + """Register I2C-HAT digital input edge callback.""" + with self._lock: + i2c_hat = self._i2c_hats[address] + self._di_scanner.register_callback(i2c_hat, channel, callback) + + def register_online_callback(self, address, channel, callback): + """Register I2C-HAT online callback.""" + with self._lock: + i2c_hat = self._i2c_hats[address] + callbacks = getattr(i2c_hat, self._CALLBACKS) + callbacks[channel] = callback + setattr(i2c_hat, self._CALLBACKS, callbacks) + + def read_di(self, address, channel): + """Read a value from a I2C-HAT digital input.""" + # pylint: disable=import-error + from raspihats.i2c_hats import ResponseException + + with self._lock: + i2c_hat = self._i2c_hats[address] + try: + value = i2c_hat.di.value + return (value >> channel) & 0x01 + except ResponseException as ex: + raise I2CHatsException(str(ex)) + + def write_dq(self, address, channel, value): + """Write a value to a I2C-HAT digital output.""" + # pylint: disable=import-error + from raspihats.i2c_hats import ResponseException + + with self._lock: + i2c_hat = self._i2c_hats[address] + try: + i2c_hat.dq.channels[channel] = value + except ResponseException as ex: + raise I2CHatsException(str(ex)) + + def read_dq(self, address, channel): + """Read a value from a I2C-HAT digital output.""" + # pylint: disable=import-error + from raspihats.i2c_hats import ResponseException + + with self._lock: + i2c_hat = self._i2c_hats[address] + try: + return i2c_hat.dq.channels[channel] + except ResponseException as ex: + raise I2CHatsException(str(ex)) diff --git a/requirements_all.txt b/requirements_all.txt index 4f0edd805e1..9e917905b4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -721,6 +721,9 @@ qnapstats==0.2.4 # homeassistant.components.climate.radiotherm radiotherm==1.2 +# homeassistant.components.raspihats +# raspihats==2.2.1 + # homeassistant.components.rflink rflink==0.0.31 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4d08ff349a0..b6424fc5fdd 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -8,6 +8,7 @@ import sys COMMENT_REQUIREMENTS = ( 'RPi.GPIO', + 'raspihats', 'rpi-rf', 'Adafruit_Python_DHT', 'Adafruit_BBIO', From 4b5be750b205c4bd5d37196c7bfc0b8d991a826f Mon Sep 17 00:00:00 2001 From: Gergely Imreh Date: Fri, 5 May 2017 19:37:54 +0100 Subject: [PATCH 023/135] sensor.envirophat: add missing requirement (#7451) Adding requirements that is not explicitly pulled in by the library that manages the Enviro pHAT. --- homeassistant/components/sensor/envirophat.py | 3 ++- requirements_all.txt | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index 0c4bb42cf8f..48370d76c83 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -15,7 +15,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['envirophat==0.0.6'] +REQUIREMENTS = ['envirophat==0.0.6', + 'smbus-cffi==0.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9e917905b4d..ffa31684a41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -767,6 +767,9 @@ sleekxmpp==1.3.2 # homeassistant.components.sleepiq sleepyq==0.6 +# homeassistant.components.sensor.envirophat +smbus-cffi==0.5.1 + # homeassistant.components.media_player.snapcast snapcast==1.2.2 From 2e4ae3e73d877ec4aa6d5fa2235bae6658ba5b87 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 5 May 2017 17:57:14 -0400 Subject: [PATCH 024/135] PyPI Openzwave (#7415) * Remove default zwave config path PYOZW now has much more comprehensive default handling for the config path (in src-lib/libopenzwave/libopenzwave.pyx:getConfig()). It looks in the same place we were looking, plus _many_ more. It will certainly do a much better job of finding the config files than we will (and will be updated as the library is changed, so we don't end up chasing it). The getConfig() method has been there for a while, but was subsntially improved recently. This change simply leaves the config_path as None if it is not specified, which will trigger the default handling in PYOZW. * Install python-openzwave from PyPI As of version 0.4, python-openzwave supports installation from PyPI, which means we can use our 'normal' dependency management tooling to install it. Yay. This uses the default 'embed' build (which goes and downloads statically sources to avoid having to compile anything locally). Check out the python-openzwave readme for more details. * Add python-openzwave deps to .travis.yml Python OpenZwave require the libudev headers to build. This adds the libudev-dev package to Travis runs via the 'apt' addon for Travis. Thanks to @MartinHjelmare for this fix. * Update docker build for PyPI openzwave Now that PYOZW can be install from PyPI, the docker image build process can be simplified to remove the explicit compilation of PYOZW. --- .travis.yml | 4 +++ homeassistant/components/zwave/__init__.py | 15 ++-------- requirements_all.txt | 3 ++ virtualization/Docker/Dockerfile.dev | 1 - .../Docker/scripts/python_openzwave | 30 ------------------- virtualization/Docker/setup_docker_prereqs | 9 ++---- 6 files changed, 11 insertions(+), 51 deletions(-) delete mode 100755 virtualization/Docker/scripts/python_openzwave diff --git a/.travis.yml b/.travis.yml index 864699a2fbd..aad5cc7028a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,8 @@ sudo: false +addons: + apt: + packages: + - libudev-dev matrix: fast_finish: true include: diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index d6077763464..19a813c1ed6 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -35,7 +35,7 @@ from . import workaround from .discovery_schemas import DISCOVERY_SCHEMAS from .util import check_node_schema, check_value_schema, node_name -REQUIREMENTS = ['pydispatcher==2.0.5'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.0.31'] _LOGGER = logging.getLogger(__name__) @@ -221,22 +221,12 @@ def setup(hass, config): descriptions = conf_util.load_yaml_config_file( os.path.join(os.path.dirname(__file__), 'services.yaml')) - try: - import libopenzwave - except ImportError: - _LOGGER.error("You are missing required dependency Python Open " - "Z-Wave. Please follow instructions at: " - "https://home-assistant.io/components/zwave/") - return False from pydispatch import dispatcher # pylint: disable=import-error from openzwave.option import ZWaveOption from openzwave.network import ZWaveNetwork from openzwave.group import ZWaveGroup - default_zwave_config_path = os.path.join(os.path.dirname( - libopenzwave.__file__), 'config') - # Load configuration use_debug = config[DOMAIN].get(CONF_DEBUG) autoheal = config[DOMAIN].get(CONF_AUTOHEAL) @@ -249,8 +239,7 @@ def setup(hass, config): options = ZWaveOption( config[DOMAIN].get(CONF_USB_STICK_PATH), user_path=hass.config.config_dir, - config_path=config[DOMAIN].get( - CONF_CONFIG_PATH, default_zwave_config_path)) + config_path=config[DOMAIN].get(CONF_CONFIG_PATH)) options.set_console_output(use_debug) options.lock() diff --git a/requirements_all.txt b/requirements_all.txt index ffa31684a41..a516837fbac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -691,6 +691,9 @@ python-vlc==1.1.2 # homeassistant.components.wink python-wink==1.2.4 +# homeassistant.components.zwave +python_openzwave==0.4.0.31 + # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 5d16e9400ef..0d546d12eb0 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -9,7 +9,6 @@ MAINTAINER Paulus Schoutsen #ENV INSTALL_TELLSTICK no #ENV INSTALL_OPENALPR no #ENV INSTALL_FFMPEG no -#ENV INSTALL_OPENZWAVE no #ENV INSTALL_LIBCEC no #ENV INSTALL_PHANTOMJS no #ENV INSTALL_COAP_CLIENT no diff --git a/virtualization/Docker/scripts/python_openzwave b/virtualization/Docker/scripts/python_openzwave deleted file mode 100755 index 85a41890186..00000000000 --- a/virtualization/Docker/scripts/python_openzwave +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/sh -# Sets up python-openzwave. -# Dependencies that need to be installed: -# apt-get install cython3 libudev-dev python3-sphinx python3-setuptools - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -if [ ! -d build ]; then - mkdir build -fi - -cd build - -if [ -d python-openzwave ]; then - cd python-openzwave - git checkout v0.3.3 -else - git clone --branch v0.3.3 --recursive --depth 1 https://github.com/OpenZWave/python-openzwave.git - cd python-openzwave -fi - -pip3 install --upgrade cython==0.24.1 -PYTHON_EXEC=`which python3` make build -PYTHON_EXEC=`which python3` make install - -mkdir -p /usr/local/share/python-openzwave -cp -R openzwave/config /usr/local/share/python-openzwave/config diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index 69f76e927e2..a6bf716312d 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -7,7 +7,6 @@ set -e INSTALL_TELLSTICK="${INSTALL_TELLSTICK:-yes}" INSTALL_OPENALPR="${INSTALL_OPENALPR:-yes}" INSTALL_FFMPEG="${INSTALL_FFMPEG:-yes}" -INSTALL_OPENZWAVE="${INSTALL_OPENZWAVE:-yes}" INSTALL_LIBCEC="${INSTALL_LIBCEC:-yes}" INSTALL_PHANTOMJS="${INSTALL_PHANTOMJS:-yes}" INSTALL_COAP_CLIENT="${INSTALL_COAP_CLIENT:-yes}" @@ -24,13 +23,13 @@ PACKAGES=( bluetooth libglib2.0-dev libbluetooth-dev # homeassistant.components.device_tracker.owntracks libsodium13 + # homeassistant.components.zwave + libudev-dev ) # Required debian packages for building dependencies PACKAGES_DEV=( cmake git - # python-openzwave - cython3 libudev-dev # libcec swig ) @@ -51,10 +50,6 @@ if [ "$INSTALL_FFMPEG" == "yes" ]; then virtualization/Docker/scripts/ffmpeg fi -if [ "$INSTALL_OPENZWAVE" == "yes" ]; then - virtualization/Docker/scripts/python_openzwave -fi - if [ "$INSTALL_LIBCEC" == "yes" ]; then virtualization/Docker/scripts/libcec fi From 20ded1ba3e3f5f3053dab0e8500a15b894f57a03 Mon Sep 17 00:00:00 2001 From: Nuno Sousa Date: Fri, 5 May 2017 23:34:40 +0100 Subject: [PATCH 025/135] Add datadog component (#7158) * Add datadog component * Improve test_invalid_config datadog test * Use assert_setup_component for test setup --- homeassistant/components/datadog.py | 120 +++++++++++++++++++ homeassistant/components/logbook.py | 5 +- homeassistant/const.py | 1 + requirements_all.txt | 3 + tests/components/test_datadog.py | 179 ++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/datadog.py create mode 100644 tests/components/test_datadog.py diff --git a/homeassistant/components/datadog.py b/homeassistant/components/datadog.py new file mode 100644 index 00000000000..2c8145177b7 --- /dev/null +++ b/homeassistant/components/datadog.py @@ -0,0 +1,120 @@ +""" +A component which allows you to send data to Datadog. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/datadog/ +""" +import logging +import voluptuous as vol + +from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_PREFIX, + EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, + STATE_UNKNOWN) +from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['datadog==0.15.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RATE = 'rate' +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 8125 +DEFAULT_PREFIX = 'hass' +DEFAULT_RATE = 1 +DOMAIN = 'datadog' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, + vol.Optional(CONF_RATE, default=DEFAULT_RATE): + vol.All(vol.Coerce(int), vol.Range(min=1)), + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Setup the Datadog component.""" + from datadog import initialize, statsd + + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + sample_rate = conf.get(CONF_RATE) + prefix = conf.get(CONF_PREFIX) + + initialize(statsd_host=host, statsd_port=port) + + def logbook_entry_listener(event): + """Listen for logbook entries and send them as events.""" + name = event.data.get('name') + message = event.data.get('message') + + statsd.event( + title="Home Assistant", + text="%%% \n **{}** {} \n %%%".format(name, message), + tags=[ + "entity:{}".format(event.data.get('entity_id')), + "domain:{}".format(event.data.get('domain')) + ] + ) + + _LOGGER.debug('Sent event %s', event.data.get('entity_id')) + + def state_changed_listener(event): + """Listen for new messages on the bus and sends them to Datadog.""" + state = event.data.get('new_state') + + if state is None or state.state == STATE_UNKNOWN: + return + + if state.attributes.get('hidden') is True: + return + + states = dict(state.attributes) + metric = "{}.{}".format(prefix, state.domain) + tags = ["entity:{}".format(state.entity_id)] + + for key, value in states.items(): + if isinstance(value, (float, int)): + attribute = "{}.{}".format(metric, key.replace(' ', '_')) + statsd.gauge( + attribute, + value, + sample_rate=sample_rate, + tags=tags + ) + + _LOGGER.debug( + 'Sent metric %s: %s (tags: %s)', + attribute, + value, + tags + ) + + try: + value = state_helper.state_as_number(state) + except ValueError: + _LOGGER.debug( + 'Error sending %s: %s (tags: %s)', + metric, + state.state, + tags + ) + return + + statsd.gauge( + metric, + value, + sample_rate=sample_rate, + tags=tags + ) + + _LOGGER.debug('Sent metric %s: %s (tags: %s)', metric, value, tags) + + hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) + hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) + + return True diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 98a0973a807..bdc3fa3dce3 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -19,7 +19,8 @@ from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, - STATE_NOT_HOME, STATE_OFF, STATE_ON, ATTR_HIDDEN, HTTP_BAD_REQUEST) + STATE_NOT_HOME, STATE_OFF, STATE_ON, ATTR_HIDDEN, HTTP_BAD_REQUEST, + EVENT_LOGBOOK_ENTRY) from homeassistant.core import State, split_entity_id, DOMAIN as HA_DOMAIN DOMAIN = 'logbook' @@ -47,8 +48,6 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) -EVENT_LOGBOOK_ENTRY = 'logbook_entry' - GROUP_BY_MINUTES = 15 ATTR_NAME = 'name' diff --git a/homeassistant/const.py b/homeassistant/const.py index c0ec2202a25..5b2367db718 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -172,6 +172,7 @@ EVENT_PLATFORM_DISCOVERED = 'platform_discovered' EVENT_COMPONENT_LOADED = 'component_loaded' EVENT_SERVICE_REGISTERED = 'service_registered' EVENT_SERVICE_REMOVED = 'service_removed' +EVENT_LOGBOOK_ENTRY = 'logbook_entry' # #### STATES #### STATE_ON = 'on' diff --git a/requirements_all.txt b/requirements_all.txt index a516837fbac..f8b6cb1f125 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -124,6 +124,9 @@ concord232==0.14 # homeassistant.components.sensor.crimereports crimereports==1.0.0 +# homeassistant.components.datadog +datadog==0.15.0 + # homeassistant.components.sensor.metoffice # homeassistant.components.weather.metoffice datapoint==0.4.3 diff --git a/tests/components/test_datadog.py b/tests/components/test_datadog.py new file mode 100644 index 00000000000..7e051161fc3 --- /dev/null +++ b/tests/components/test_datadog.py @@ -0,0 +1,179 @@ +"""The tests for the Datadog component.""" +from unittest import mock +import unittest + +from homeassistant.const import ( + EVENT_LOGBOOK_ENTRY, + EVENT_STATE_CHANGED, + STATE_OFF, + STATE_ON +) +from homeassistant.setup import setup_component +import homeassistant.components.datadog as datadog +import homeassistant.core as ha + +from tests.common import (assert_setup_component, get_test_home_assistant) + + +class TestDatadog(unittest.TestCase): + """Test the Datadog component.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_invalid_config(self): + """Test invalid configuration.""" + with assert_setup_component(0): + assert not setup_component(self.hass, datadog.DOMAIN, { + datadog.DOMAIN: { + 'host1': 'host1' + } + }) + + @mock.patch('datadog.initialize') + def test_datadog_setup_full(self, mock_connection): + """Test setup with all data.""" + self.hass.bus.listen = mock.MagicMock() + + assert setup_component(self.hass, datadog.DOMAIN, { + datadog.DOMAIN: { + 'host': 'host', + 'port': 123, + 'rate': 1, + 'prefix': 'foo', + } + }) + + self.assertEqual(mock_connection.call_count, 1) + self.assertEqual( + mock_connection.call_args, + mock.call(statsd_host='host', statsd_port=123) + ) + + self.assertTrue(self.hass.bus.listen.called) + self.assertEqual(EVENT_LOGBOOK_ENTRY, + self.hass.bus.listen.call_args_list[0][0][0]) + self.assertEqual(EVENT_STATE_CHANGED, + self.hass.bus.listen.call_args_list[1][0][0]) + + @mock.patch('datadog.initialize') + def test_datadog_setup_defaults(self, mock_connection): + """Test setup with defaults.""" + self.hass.bus.listen = mock.MagicMock() + + assert setup_component(self.hass, datadog.DOMAIN, { + datadog.DOMAIN: { + 'host': 'host', + 'port': datadog.DEFAULT_PORT, + 'prefix': datadog.DEFAULT_PREFIX, + } + }) + + self.assertEqual(mock_connection.call_count, 1) + self.assertEqual( + mock_connection.call_args, + mock.call(statsd_host='host', statsd_port=8125) + ) + self.assertTrue(self.hass.bus.listen.called) + + @mock.patch('datadog.statsd') + def test_logbook_entry(self, mock_client): + """Test event listener.""" + self.hass.bus.listen = mock.MagicMock() + + assert setup_component(self.hass, datadog.DOMAIN, { + datadog.DOMAIN: { + 'host': 'host', + 'rate': datadog.DEFAULT_RATE, + } + }) + + self.assertTrue(self.hass.bus.listen.called) + handler_method = self.hass.bus.listen.call_args_list[0][0][1] + + event = { + 'domain': 'automation', + 'entity_id': 'sensor.foo.bar', + 'message': 'foo bar biz', + 'name': 'triggered something' + } + handler_method(mock.MagicMock(data=event)) + + self.assertEqual(mock_client.event.call_count, 1) + self.assertEqual( + mock_client.event.call_args, + mock.call( + title="Home Assistant", + text="%%% \n **{}** {} \n %%%".format( + event['name'], + event['message'] + ), + tags=["entity:sensor.foo.bar", "domain:automation"] + ) + ) + + mock_client.event.reset_mock() + + @mock.patch('datadog.statsd') + def test_state_changed(self, mock_client): + """Test event listener.""" + self.hass.bus.listen = mock.MagicMock() + + assert setup_component(self.hass, datadog.DOMAIN, { + datadog.DOMAIN: { + 'host': 'host', + 'prefix': 'ha', + 'rate': datadog.DEFAULT_RATE, + } + }) + + self.assertTrue(self.hass.bus.listen.called) + handler_method = self.hass.bus.listen.call_args_list[1][0][1] + + valid = { + '1': 1, + '1.0': 1.0, + STATE_ON: 1, + STATE_OFF: 0 + } + + attributes = { + 'elevation': 3.2, + 'temperature': 5.0 + } + + for in_, out in valid.items(): + state = mock.MagicMock(domain="sensor", entity_id="sensor.foo.bar", + state=in_, attributes=attributes) + handler_method(mock.MagicMock(data={'new_state': state})) + + self.assertEqual(mock_client.gauge.call_count, 3) + + for attribute, value in attributes.items(): + mock_client.gauge.assert_has_calls([ + mock.call( + "ha.sensor.{}".format(attribute), + value, + sample_rate=1, + tags=["entity:{}".format(state.entity_id)] + ) + ]) + + self.assertEqual( + mock_client.gauge.call_args, + mock.call("ha.sensor", out, sample_rate=1, tags=[ + "entity:{}".format(state.entity_id) + ]) + ) + + mock_client.gauge.reset_mock() + + for invalid in ('foo', '', object): + handler_method(mock.MagicMock(data={ + 'new_state': ha.State('domain.test', invalid, {})})) + self.assertFalse(mock_client.gauge.called) From 2971a24c56048ce7dd819c65732acc36347da2f3 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 5 May 2017 19:19:24 -0400 Subject: [PATCH 026/135] Fix object type for default KNX port #7429 describes a TypeError that is raised if the port is omitted in the config for the KNX component (integer is required (got type str)). This commit changes the default port from a string to an integer. I expect this will resolve that issue... --- homeassistant/components/knx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index f72a3048dec..ff951e55810 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -18,7 +18,7 @@ REQUIREMENTS = ['knxip==0.3.3'] _LOGGER = logging.getLogger(__name__) DEFAULT_HOST = '0.0.0.0' -DEFAULT_PORT = '3671' +DEFAULT_PORT = 3671 DOMAIN = 'knx' EVENT_KNX_FRAME_RECEIVED = 'knx_frame_received' From 7dd7f509ca45f66f1a3d98fb2fef7054feed238b Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 6 May 2017 13:10:48 -0400 Subject: [PATCH 027/135] Add tests for deprecation helpers (#7452) --- tests/helpers/test_deprecation.py | 85 +++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/helpers/test_deprecation.py diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py new file mode 100644 index 00000000000..8064c2ea5d6 --- /dev/null +++ b/tests/helpers/test_deprecation.py @@ -0,0 +1,85 @@ +"""Test deprecation helpers.""" +from homeassistant.helpers.deprecation import ( + deprecated_substitute, get_deprecated) + +from unittest.mock import patch, MagicMock + + +class MockBaseClass(): + """Mock base class for deprecated testing.""" + + @property + @deprecated_substitute('old_property') + def new_property(self): + """Test property to fetch.""" + raise NotImplementedError() + + +class MockDeprecatedClass(MockBaseClass): + """Mock deprecated class object.""" + + @property + def old_property(self): + """Test property to fetch.""" + return True + + +class MockUpdatedClass(MockBaseClass): + """Mock updated class object.""" + + @property + def new_property(self): + """Test property to fetch.""" + return True + + +@patch('logging.getLogger') +def test_deprecated_substitute_old_class(mock_get_logger): + """Test deprecated class object.""" + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + mock_object = MockDeprecatedClass() + assert mock_object.new_property is True + assert mock_object.new_property is True + assert mock_logger.warning.called + assert len(mock_logger.warning.mock_calls) == 1 + + +@patch('logging.getLogger') +def test_deprecated_substitute_new_class(mock_get_logger): + """Test deprecated class object.""" + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + mock_object = MockUpdatedClass() + assert mock_object.new_property is True + assert mock_object.new_property is True + assert not mock_logger.warning.called + + +@patch('logging.getLogger') +def test_config_get_deprecated_old(mock_get_logger): + """Test deprecated class object.""" + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + config = { + 'old_name': True, + } + assert get_deprecated(config, 'new_name', 'old_name') is True + assert mock_logger.warning.called + assert len(mock_logger.warning.mock_calls) == 1 + + +@patch('logging.getLogger') +def test_config_get_deprecated_new(mock_get_logger): + """Test deprecated class object.""" + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + config = { + 'new_name': True, + } + assert get_deprecated(config, 'new_name', 'old_name') is True + assert not mock_logger.warning.called From 7a70496b116569c0581927ed30a5c884dc379c42 Mon Sep 17 00:00:00 2001 From: pezinek Date: Sat, 6 May 2017 19:11:31 +0200 Subject: [PATCH 028/135] Forecasts for weather underground (#7062) --- .../components/sensor/wunderground.py | 708 +++++++++++++++--- tests/components/sensor/test_wunderground.py | 72 +- 2 files changed, 666 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index d50f6b0897c..4d684f405f8 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -14,13 +14,13 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_API_KEY, TEMP_FAHRENHEIT, TEMP_CELSIUS, - STATE_UNKNOWN, ATTR_ATTRIBUTION) + LENGTH_INCHES, LENGTH_KILOMETERS, LENGTH_MILES, LENGTH_FEET, + STATE_UNKNOWN, ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -_RESOURCE = 'http://api.wunderground.com/api/{}/conditions/{}/q/' -_ALERTS = 'http://api.wunderground.com/api/{}/alerts/{}/q/' +_RESOURCE = 'http://api.wunderground.com/api/{}/{}/{}/q/' _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by the WUnderground weather service" @@ -29,50 +29,562 @@ CONF_LANG = 'lang' DEFAULT_LANG = 'EN' -MIN_TIME_BETWEEN_UPDATES_ALERTS = timedelta(minutes=15) -MIN_TIME_BETWEEN_UPDATES_OBSERVATION = timedelta(minutes=5) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + + +# Helper classes for declaring sensor configurations + +class WUSensorConfig(object): + """WU Sensor Configuration. + + defines basic HA properties of the weather sensor and + stores callbacks that can parse sensor values out of + the json data received by WU API. + """ + + def __init__(self, friendly_name, feature, value, + unit_of_measurement=None, entity_picture=None, + icon="mdi:gauge", device_state_attributes=None): + """Constructor. + + Args: + friendly_name (string|func): Friendly name + feature (string): WU feature. See: + https://www.wunderground.com/weather/api/d/docs?d=data/index + value (function(WUndergroundData)): callback that + extracts desired value from WUndergroundData object + unit_of_measurement (string): unit of meassurement + entity_picture (string): value or callback returning + URL of entity picture + icon (string): icon name or URL + device_state_attributes (dict): dictionary of attributes, + or callable that returns it + """ + self.friendly_name = friendly_name + self.unit_of_measurement = unit_of_measurement + self.feature = feature + self.value = value + self.entity_picture = entity_picture + self.icon = icon + self.device_state_attributes = device_state_attributes or {} + + +class WUCurrentConditionsSensorConfig(WUSensorConfig): + """Helper for defining sensor configurations for current conditions.""" + + def __init__(self, friendly_name, field, icon="mdi:gauge", + unit_of_measurement=None): + """Constructor. + + Args: + friendly_name (string|func): Friendly name of sensor + field (string): Field name in the "current_observation" + dictionary. + icon (string): icon name or URL, if None sensor + will use current weather symbol + unit_of_measurement (string): unit of meassurement + """ + super().__init__( + friendly_name, + "conditions", + value=lambda wu: wu.data['current_observation'][field], + icon=icon, + unit_of_measurement=unit_of_measurement, + entity_picture=lambda wu: wu.data['current_observation'][ + 'icon_url'] if icon is None else None, + device_state_attributes={ + 'date': lambda wu: wu.data['current_observation'][ + 'observation_time'] + } + ) + + +class WUDailyTextForecastSensorConfig(WUSensorConfig): + """Helper for defining sensor configurations for daily text forecasts.""" + + def __init__(self, period, field, unit_of_measurement=None): + """Constructor. + + Args: + period (int): forecast period number + field (string): field name to use as value + unit_of_measurement(string): unit of measurement + """ + super().__init__( + friendly_name=lambda wu: wu.data['forecast']['txt_forecast'][ + 'forecastday'][period]['title'], + feature='forecast', + value=lambda wu: wu.data['forecast']['txt_forecast'][ + 'forecastday'][period][field], + entity_picture=lambda wu: wu.data['forecast']['txt_forecast'][ + 'forecastday'][period]['icon_url'], + unit_of_measurement=unit_of_measurement, + device_state_attributes={ + 'date': lambda wu: wu.data['forecast']['txt_forecast']['date'] + } + ) + + +class WUDailySimpleForecastSensorConfig(WUSensorConfig): + """Helper for defining sensor configurations for daily simpleforecasts.""" + + def __init__(self, friendly_name, period, field, wu_unit=None, + ha_unit=None, icon=None): + """Constructor. + + Args: + period (int): forecast period number + field (string): field name to use as value + wu_unit (string): "fahrenheit", "celsius", "degrees" etc. + see the example json at: + https://www.wunderground.com/weather/api/d/docs?d=data/forecast&MR=1 + ha_unit (string): coresponding unit in home assistant + title (string): friendly_name of the sensor + """ + super().__init__( + friendly_name=friendly_name, + feature='forecast', + value=(lambda wu: wu.data['forecast']['simpleforecast'][ + 'forecastday'][period][field][wu_unit]) + if wu_unit else + (lambda wu: wu.data['forecast']['simpleforecast'][ + 'forecastday'][period][field]), + unit_of_measurement=ha_unit, + entity_picture=lambda wu: wu.data['forecast']['simpleforecast'][ + 'forecastday'][period]['icon_url'] if not icon else None, + icon=icon, + device_state_attributes={ + 'date': lambda wu: wu.data['forecast']['simpleforecast'][ + 'forecastday'][period]['date']['pretty'] + } + ) + + +class WUHourlyForecastSensorConfig(WUSensorConfig): + """Helper for defining sensor configurations for hourly text forecasts.""" + + def __init__(self, period, field): + """Constructor. + + Args: + period (int): forecast period number + field (int): field name to use as value + """ + super().__init__( + friendly_name=lambda wu: "{} {}".format( + wu.data['hourly_forecast'][period]['FCTTIME'][ + 'weekday_name_abbrev'], + wu.data['hourly_forecast'][period]['FCTTIME'][ + 'civil']), + feature='hourly', + value=lambda wu: wu.data['hourly_forecast'][period][ + field], + entity_picture=lambda wu: wu.data['hourly_forecast'][ + period]["icon_url"], + device_state_attributes={ + 'temp_c': lambda wu: wu.data['hourly_forecast'][ + period]['temp']['metric'], + 'temp_f': lambda wu: wu.data['hourly_forecast'][ + period]['temp']['english'], + 'dewpoint_c': lambda wu: wu.data['hourly_forecast'][ + period]['dewpoint']['metric'], + 'dewpoint_f': lambda wu: wu.data['hourly_forecast'][ + period]['dewpoint']['english'], + 'precip_prop': lambda wu: wu.data['hourly_forecast'][ + period]['pop'], + 'sky': lambda wu: wu.data['hourly_forecast'][ + period]['sky'], + 'precip_mm': lambda wu: wu.data['hourly_forecast'][ + period]['qpf']['metric'], + 'precip_in': lambda wu: wu.data['hourly_forecast'][ + period]['qpf']['english'], + 'humidity': lambda wu: wu.data['hourly_forecast'][ + period]['humidity'], + 'wind_kph': lambda wu: wu.data['hourly_forecast'][ + period]['wspd']['metric'], + 'wind_mph': lambda wu: wu.data['hourly_forecast'][ + period]['wspd']['english'], + 'pressure_mb': lambda wu: wu.data['hourly_forecast'][ + period]['mslp']['metric'], + 'pressure_inHg': lambda wu: wu.data['hourly_forecast'][ + period]['mslp']['english'], + 'date': lambda wu: wu.data['hourly_forecast'][ + period]['FCTTIME']['pretty'], + }, + ) + + +class WUAlmanacSensorConfig(WUSensorConfig): + """Helper for defining field configurations for almanac sensors.""" + + def __init__(self, friendly_name, field, value_type, wu_unit, + unit_of_measurement, icon): + """Constructor. + + Args: + friendly_name (string|func): Friendly name + field (string): value name returned in 'almanac' dict + as returned by the WU API + value_type (string): "record" or "normal" + wu_unit (string): unit name in WU API + icon (string): icon name or URL + unit_of_measurement (string): unit of meassurement + """ + super().__init__( + friendly_name=friendly_name, + feature="almanac", + value=lambda wu: wu.data['almanac'][field][value_type][wu_unit], + unit_of_measurement=unit_of_measurement, + icon=icon + ) + + +class WUAlertsSensorConfig(WUSensorConfig): + """Helper for defining field configuration for alerts.""" + + def __init__(self, friendly_name): + """Constructor. + + Args: + friendly_name (string|func): Friendly name + """ + super().__init__( + friendly_name=friendly_name, + feature="alerts", + value=lambda wu: len(wu.data['alerts']), + icon=lambda wu: "mdi:alert-circle-outline" + if len(wu.data['alerts']) > 0 + else "mdi:check-circle-outline", + device_state_attributes=self._get_attributes + ) + + @staticmethod + def _get_attributes(rest): + + attrs = {} + + if 'alerts' not in rest.data: + return attrs + + alerts = rest.data['alerts'] + multiple_alerts = len(alerts) > 1 + for data in alerts: + for alert in ALERTS_ATTRS: + if data[alert]: + if multiple_alerts: + dkey = alert.capitalize() + '_' + data['type'] + else: + dkey = alert.capitalize() + attrs[dkey] = data[alert] + return attrs + + +# Declaration of supported WU sensors +# (see above helper classes for argument explanation) -# Sensor types are defined like: Name, units SENSOR_TYPES = { - 'alerts': ['Alerts', None], - 'dewpoint_c': ['Dewpoint (°C)', TEMP_CELSIUS], - 'dewpoint_f': ['Dewpoint (°F)', TEMP_FAHRENHEIT], - 'dewpoint_string': ['Dewpoint Summary', None], - 'feelslike_c': ['Feels Like (°C)', TEMP_CELSIUS], - 'feelslike_f': ['Feels Like (°F)', TEMP_FAHRENHEIT], - 'feelslike_string': ['Feels Like', None], - 'heat_index_c': ['Dewpoint (°C)', TEMP_CELSIUS], - 'heat_index_f': ['Dewpoint (°F)', TEMP_FAHRENHEIT], - 'heat_index_string': ['Heat Index Summary', None], - 'elevation': ['Elevation', 'ft'], - 'location': ['Location', None], - 'observation_time': ['Observation Time', None], - 'precip_1hr_in': ['Precipation 1hr', 'in'], - 'precip_1hr_metric': ['Precipation 1hr', 'mm'], - 'precip_1hr_string': ['Precipation 1hr', None], - 'precip_today_in': ['Precipation Today', 'in'], - 'precip_today_metric': ['Precipitation Today', 'mm'], - 'precip_today_string': ['Precipitation today', None], - 'pressure_in': ['Pressure', 'in'], - 'pressure_mb': ['Pressure', 'mb'], - 'pressure_trend': ['Pressure Trend', None], - 'relative_humidity': ['Relative Humidity', '%'], - 'station_id': ['Station ID', None], - 'solarradiation': ['Solar Radiation', None], - 'temperature_string': ['Temperature Summary', None], - 'temp_c': ['Temperature (°C)', TEMP_CELSIUS], - 'temp_f': ['Temperature (°F)', TEMP_FAHRENHEIT], - 'UV': ['UV', None], - 'visibility_km': ['Visibility (km)', 'km'], - 'visibility_mi': ['Visibility (miles)', 'mi'], - 'weather': ['Weather Summary', None], - 'wind_degrees': ['Wind Degrees', None], - 'wind_dir': ['Wind Direction', None], - 'wind_gust_kph': ['Wind Gust', 'kph'], - 'wind_gust_mph': ['Wind Gust', 'mph'], - 'wind_kph': ['Wind Speed', 'kph'], - 'wind_mph': ['Wind Speed', 'mph'], - 'wind_string': ['Wind Summary', None], + 'alerts': WUAlertsSensorConfig('Alerts'), + 'dewpoint_c': WUCurrentConditionsSensorConfig( + 'Dewpoint', 'dewpoint_c', 'mdi:water', TEMP_CELSIUS), + 'dewpoint_f': WUCurrentConditionsSensorConfig( + 'Dewpoint', 'dewpoint_f', 'mdi:water', TEMP_FAHRENHEIT), + 'dewpoint_string': WUCurrentConditionsSensorConfig( + 'Dewpoint Summary', 'dewpoint_string', 'mdi:water'), + 'feelslike_c': WUCurrentConditionsSensorConfig( + 'Feels Like', 'feelslike_c', 'mdi:thermometer', TEMP_CELSIUS), + 'feelslike_f': WUCurrentConditionsSensorConfig( + 'Feels Like', 'feelslike_f', 'mdi:thermometer', TEMP_FAHRENHEIT), + 'feelslike_string': WUCurrentConditionsSensorConfig( + 'Feels Like', 'feelslike_string', "mdi:thermometer"), + 'heat_index_c': WUCurrentConditionsSensorConfig( + 'Heat index', 'heat_index_c', "mdi:thermometer", TEMP_CELSIUS), + 'heat_index_f': WUCurrentConditionsSensorConfig( + 'Heat index', 'heat_index_f', "mdi:thermometer", TEMP_FAHRENHEIT), + 'heat_index_string': WUCurrentConditionsSensorConfig( + 'Heat Index Summary', 'heat_index_string', "mdi:thermometer"), + 'elevation': WUSensorConfig( + 'Elevation', + 'conditions', + value=lambda wu: wu.data['current_observation'][ + 'observation_location']['elevation'].split()[0], + unit_of_measurement=LENGTH_FEET, + icon="mdi:elevation-rise"), + 'location': WUSensorConfig( + 'Location', + 'conditions', + value=lambda wu: wu.data['current_observation'][ + 'display_location']['full'], + icon="mdi:map-marker"), + 'observation_time': WUCurrentConditionsSensorConfig( + 'Observation Time', 'observation_time', "mdi:clock"), + 'precip_1hr_in': WUCurrentConditionsSensorConfig( + 'Precipitation 1hr', 'precip_1hr_in', "mdi:umbrella", LENGTH_INCHES), + 'precip_1hr_metric': WUCurrentConditionsSensorConfig( + 'Precipitation 1hr', 'precip_1hr_metric', "mdi:umbrella", 'mm'), + 'precip_1hr_string': WUCurrentConditionsSensorConfig( + 'Precipitation 1hr', 'precip_1hr_string', "mdi:umbrella"), + 'precip_today_in': WUCurrentConditionsSensorConfig( + 'Precipitation Today', 'precip_today_in', "mdi:umbrella", + LENGTH_INCHES), + 'precip_today_metric': WUCurrentConditionsSensorConfig( + 'Precipitation Today', 'precip_today_metric', "mdi:umbrella", 'mm'), + 'precip_today_string': WUCurrentConditionsSensorConfig( + 'Precipitation Today', 'precip_today_string', "mdi:umbrella"), + 'pressure_in': WUCurrentConditionsSensorConfig( + 'Pressure', 'pressure_in', "mdi:gauge", 'inHg'), + 'pressure_mb': WUCurrentConditionsSensorConfig( + 'Pressure', 'pressure_mb', "mdi:gauge", 'mb'), + 'pressure_trend': WUCurrentConditionsSensorConfig( + 'Pressure Trend', 'pressure_trend', "mdi:gauge"), + 'relative_humidity': WUSensorConfig( + 'Relative Humidity', + 'conditions', + value=lambda wu: int(wu.data['current_observation'][ + 'relative_humidity'][:-1]), + unit_of_measurement='%', + icon="mdi:water-percent"), + 'station_id': WUCurrentConditionsSensorConfig( + 'Station ID', 'station_id', "mdi:home"), + 'solarradiation': WUCurrentConditionsSensorConfig( + 'Solar Radiation', 'solarradiation', "mdi:weather-sunny", "w/m2"), + 'temperature_string': WUCurrentConditionsSensorConfig( + 'Temperature Summary', 'temperature_string', "mdi:thermometer"), + 'temp_c': WUCurrentConditionsSensorConfig( + 'Temperature', 'temp_c', "mdi:thermometer", TEMP_CELSIUS), + 'temp_f': WUCurrentConditionsSensorConfig( + 'Temperature', 'temp_f', "mdi:thermometer", TEMP_FAHRENHEIT), + 'UV': WUCurrentConditionsSensorConfig( + 'UV', 'UV', "mdi:sunglasses"), + 'visibility_km': WUCurrentConditionsSensorConfig( + 'Visibility (km)', 'visibility_km', "mdi:eye", LENGTH_KILOMETERS), + 'visibility_mi': WUCurrentConditionsSensorConfig( + 'Visibility (miles)', 'visibility_mi', "mdi:eye", LENGTH_MILES), + 'weather': WUCurrentConditionsSensorConfig( + 'Weather Summary', 'weather', None), + 'wind_degrees': WUCurrentConditionsSensorConfig( + 'Wind Degrees', 'wind_degrees', "mdi:weather-windy", "°"), + 'wind_dir': WUCurrentConditionsSensorConfig( + 'Wind Direction', 'wind_dir', "mdi:weather-windy"), + 'wind_gust_kph': WUCurrentConditionsSensorConfig( + 'Wind Gust', 'wind_gust_kph', "mdi:weather-windy", 'kph'), + 'wind_gust_mph': WUCurrentConditionsSensorConfig( + 'Wind Gust', 'wind_gust_mph', "mdi:weather-windy", 'mph'), + 'wind_kph': WUCurrentConditionsSensorConfig( + 'Wind Speed', 'wind_kph', "mdi:weather-windy", 'kph'), + 'wind_mph': WUCurrentConditionsSensorConfig( + 'Wind Speed', 'wind_mph', "mdi:weather-windy", 'mph'), + 'wind_string': WUCurrentConditionsSensorConfig( + 'Wind Summary', 'wind_string', "mdi:weather-windy"), + 'temp_high_record_c': WUAlmanacSensorConfig( + lambda wu: 'High Temperature Record ({})'.format( + wu.data['almanac']['temp_high']['recordyear']), + 'temp_high', 'record', 'C', TEMP_CELSIUS, 'mdi:thermometer'), + 'temp_high_record_f': WUAlmanacSensorConfig( + lambda wu: 'High Temperature Record ({})'.format( + wu.data['almanac']['temp_high']['recordyear']), + 'temp_high', 'record', 'F', TEMP_FAHRENHEIT, 'mdi:thermometer'), + 'temp_low_record_c': WUAlmanacSensorConfig( + lambda wu: 'Low Temperature Record ({})'.format( + wu.data['almanac']['temp_low']['recordyear']), + 'temp_low', 'record', 'C', TEMP_CELSIUS, 'mdi:thermometer'), + 'temp_low_record_f': WUAlmanacSensorConfig( + lambda wu: 'Low Temperature Record ({})'.format( + wu.data['almanac']['temp_low']['recordyear']), + 'temp_low', 'record', 'F', TEMP_FAHRENHEIT, 'mdi:thermometer'), + 'temp_low_avg_c': WUAlmanacSensorConfig( + 'Historic Average of Low Temperatures for Today', + 'temp_low', 'normal', 'C', TEMP_CELSIUS, 'mdi:thermometer'), + 'temp_low_avg_f': WUAlmanacSensorConfig( + 'Historic Average of Low Temperatures for Today', + 'temp_low', 'normal', 'F', TEMP_FAHRENHEIT, 'mdi:thermometer'), + 'temp_high_avg_c': WUAlmanacSensorConfig( + 'Historic Average of High Temperatures for Today', + 'temp_high', 'normal', 'C', TEMP_CELSIUS, "mdi:thermometer"), + 'temp_high_avg_f': WUAlmanacSensorConfig( + 'Historic Average of High Temperatures for Today', + 'temp_high', 'normal', 'F', TEMP_FAHRENHEIT, "mdi:thermometer"), + 'weather_1d': WUDailyTextForecastSensorConfig(0, "fcttext"), + 'weather_1d_metric': WUDailyTextForecastSensorConfig(0, "fcttext_metric"), + 'weather_1n': WUDailyTextForecastSensorConfig(1, "fcttext"), + 'weather_1n_metric': WUDailyTextForecastSensorConfig(1, "fcttext_metric"), + 'weather_2d': WUDailyTextForecastSensorConfig(2, "fcttext"), + 'weather_2d_metric': WUDailyTextForecastSensorConfig(2, "fcttext_metric"), + 'weather_2n': WUDailyTextForecastSensorConfig(3, "fcttext"), + 'weather_2n_metric': WUDailyTextForecastSensorConfig(3, "fcttext_metric"), + 'weather_3d': WUDailyTextForecastSensorConfig(4, "fcttext"), + 'weather_3d_metric': WUDailyTextForecastSensorConfig(4, "fcttext_metric"), + 'weather_3n': WUDailyTextForecastSensorConfig(5, "fcttext"), + 'weather_3n_metric': WUDailyTextForecastSensorConfig(5, "fcttext_metric"), + 'weather_4d': WUDailyTextForecastSensorConfig(6, "fcttext"), + 'weather_4d_metric': WUDailyTextForecastSensorConfig(6, "fcttext_metric"), + 'weather_4n': WUDailyTextForecastSensorConfig(7, "fcttext"), + 'weather_4n_metric': WUDailyTextForecastSensorConfig(7, "fcttext_metric"), + 'weather_1h': WUHourlyForecastSensorConfig(0, "condition"), + 'weather_2h': WUHourlyForecastSensorConfig(1, "condition"), + 'weather_3h': WUHourlyForecastSensorConfig(2, "condition"), + 'weather_4h': WUHourlyForecastSensorConfig(3, "condition"), + 'weather_5h': WUHourlyForecastSensorConfig(4, "condition"), + 'weather_6h': WUHourlyForecastSensorConfig(5, "condition"), + 'weather_7h': WUHourlyForecastSensorConfig(6, "condition"), + 'weather_8h': WUHourlyForecastSensorConfig(7, "condition"), + 'weather_9h': WUHourlyForecastSensorConfig(8, "condition"), + 'weather_10h': WUHourlyForecastSensorConfig(9, "condition"), + 'weather_11h': WUHourlyForecastSensorConfig(10, "condition"), + 'weather_12h': WUHourlyForecastSensorConfig(11, "condition"), + 'weather_13h': WUHourlyForecastSensorConfig(12, "condition"), + 'weather_14h': WUHourlyForecastSensorConfig(13, "condition"), + 'weather_15h': WUHourlyForecastSensorConfig(14, "condition"), + 'weather_16h': WUHourlyForecastSensorConfig(15, "condition"), + 'weather_17h': WUHourlyForecastSensorConfig(16, "condition"), + 'weather_18h': WUHourlyForecastSensorConfig(17, "condition"), + 'weather_19h': WUHourlyForecastSensorConfig(18, "condition"), + 'weather_20h': WUHourlyForecastSensorConfig(19, "condition"), + 'weather_21h': WUHourlyForecastSensorConfig(20, "condition"), + 'weather_22h': WUHourlyForecastSensorConfig(21, "condition"), + 'weather_23h': WUHourlyForecastSensorConfig(22, "condition"), + 'weather_24h': WUHourlyForecastSensorConfig(23, "condition"), + 'weather_25h': WUHourlyForecastSensorConfig(24, "condition"), + 'weather_26h': WUHourlyForecastSensorConfig(25, "condition"), + 'weather_27h': WUHourlyForecastSensorConfig(26, "condition"), + 'weather_28h': WUHourlyForecastSensorConfig(27, "condition"), + 'weather_29h': WUHourlyForecastSensorConfig(28, "condition"), + 'weather_30h': WUHourlyForecastSensorConfig(29, "condition"), + 'weather_31h': WUHourlyForecastSensorConfig(30, "condition"), + 'weather_32h': WUHourlyForecastSensorConfig(31, "condition"), + 'weather_33h': WUHourlyForecastSensorConfig(32, "condition"), + 'weather_34h': WUHourlyForecastSensorConfig(33, "condition"), + 'weather_35h': WUHourlyForecastSensorConfig(34, "condition"), + 'weather_36h': WUHourlyForecastSensorConfig(35, "condition"), + 'temp_high_1d_c': WUDailySimpleForecastSensorConfig( + "High Temperature Today", 0, "high", "celsius", TEMP_CELSIUS, + "mdi:thermometer"), + 'temp_high_2d_c': WUDailySimpleForecastSensorConfig( + "High Temperature Tomorrow", 1, "high", "celsius", TEMP_CELSIUS, + "mdi:thermometer"), + 'temp_high_3d_c': WUDailySimpleForecastSensorConfig( + "High Temperature in 3 Days", 2, "high", "celsius", TEMP_CELSIUS, + "mdi:thermometer"), + 'temp_high_4d_c': WUDailySimpleForecastSensorConfig( + "High Temperature in 4 Days", 3, "high", "celsius", TEMP_CELSIUS, + "mdi:thermometer"), + 'temp_high_1d_f': WUDailySimpleForecastSensorConfig( + "High Temperature Today", 0, "high", "fahrenheit", TEMP_FAHRENHEIT, + "mdi:thermometer"), + 'temp_high_2d_f': WUDailySimpleForecastSensorConfig( + "High Temperature Tomorrow", 1, "high", "fahrenheit", TEMP_FAHRENHEIT, + "mdi:thermometer"), + 'temp_high_3d_f': WUDailySimpleForecastSensorConfig( + "High Temperature in 3 Days", 2, "high", "fahrenheit", TEMP_FAHRENHEIT, + "mdi:thermometer"), + 'temp_high_4d_f': WUDailySimpleForecastSensorConfig( + "High Temperature in 4 Days", 3, "high", "fahrenheit", TEMP_FAHRENHEIT, + "mdi:thermometer"), + 'temp_low_1d_c': WUDailySimpleForecastSensorConfig( + "Low Temperature Today", 0, "low", "celsius", TEMP_CELSIUS, + "mdi:thermometer"), + 'temp_low_2d_c': WUDailySimpleForecastSensorConfig( + "Low Temperature Tomorrow", 1, "low", "celsius", TEMP_CELSIUS, + "mdi:thermometer"), + 'temp_low_3d_c': WUDailySimpleForecastSensorConfig( + "Low Temperature in 3 Days", 2, "low", "celsius", TEMP_CELSIUS, + "mdi:thermometer"), + 'temp_low_4d_c': WUDailySimpleForecastSensorConfig( + "Low Temperature in 4 Days", 3, "low", "celsius", TEMP_CELSIUS, + "mdi:thermometer"), + 'temp_low_1d_f': WUDailySimpleForecastSensorConfig( + "Low Temperature Today", 0, "low", "fahrenheit", TEMP_FAHRENHEIT, + "mdi:thermometer"), + 'temp_low_2d_f': WUDailySimpleForecastSensorConfig( + "Low Temperature Tomorrow", 1, "low", "fahrenheit", TEMP_FAHRENHEIT, + "mdi:thermometer"), + 'temp_low_3d_f': WUDailySimpleForecastSensorConfig( + "Low Temperature in 3 Days", 2, "low", "fahrenheit", TEMP_FAHRENHEIT, + "mdi:thermometer"), + 'temp_low_4d_f': WUDailySimpleForecastSensorConfig( + "Low Temperature in 4 Days", 3, "low", "fahrenheit", TEMP_FAHRENHEIT, + "mdi:thermometer"), + 'wind_gust_1d_kph': WUDailySimpleForecastSensorConfig( + "Max. Wind Today", 0, "maxwind", "kph", "kph", "mdi:weather-windy"), + 'wind_gust_2d_kph': WUDailySimpleForecastSensorConfig( + "Max. Wind Tomorrow", 1, "maxwind", "kph", "kph", "mdi:weather-windy"), + 'wind_gust_3d_kph': WUDailySimpleForecastSensorConfig( + "Max. Wind in 3 Days", 2, "maxwind", "kph", "kph", + "mdi:weather-windy"), + 'wind_gust_4d_kph': WUDailySimpleForecastSensorConfig( + "Max. Wind in 4 Days", 3, "maxwind", "kph", "kph", + "mdi:weather-windy"), + 'wind_gust_1d_mph': WUDailySimpleForecastSensorConfig( + "Max. Wind Today", 0, "maxwind", "mph", "mph", + "mdi:weather-windy"), + 'wind_gust_2d_mph': WUDailySimpleForecastSensorConfig( + "Max. Wind Tomorrow", 1, "maxwind", "mph", "mph", + "mdi:weather-windy"), + 'wind_gust_3d_mph': WUDailySimpleForecastSensorConfig( + "Max. Wind in 3 Days", 2, "maxwind", "mph", "mph", + "mdi:weather-windy"), + 'wind_gust_4d_mph': WUDailySimpleForecastSensorConfig( + "Max. Wind in 4 Days", 3, "maxwind", "mph", "mph", + "mdi:weather-windy"), + 'wind_1d_kph': WUDailySimpleForecastSensorConfig( + "Avg. Wind Today", 0, "avewind", "kph", "kph", + "mdi:weather-windy"), + 'wind_2d_kph': WUDailySimpleForecastSensorConfig( + "Avg. Wind Tomorrow", 1, "avewind", "kph", "kph", + "mdi:weather-windy"), + 'wind_3d_kph': WUDailySimpleForecastSensorConfig( + "Avg. Wind in 3 Days", 2, "avewind", "kph", "kph", + "mdi:weather-windy"), + 'wind_4d_kph': WUDailySimpleForecastSensorConfig( + "Avg. Wind in 4 Days", 3, "avewind", "kph", "kph", + "mdi:weather-windy"), + 'wind_1d_mph': WUDailySimpleForecastSensorConfig( + "Avg. Wind Today", 0, "avewind", "mph", "mph", + "mdi:weather-windy"), + 'wind_2d_mph': WUDailySimpleForecastSensorConfig( + "Avg. Wind Tomorrow", 1, "avewind", "mph", "mph", + "mdi:weather-windy"), + 'wind_3d_mph': WUDailySimpleForecastSensorConfig( + "Avg. Wind in 3 Days", 2, "avewind", "mph", "mph", + "mdi:weather-windy"), + 'wind_4d_mph': WUDailySimpleForecastSensorConfig( + "Avg. Wind in 4 Days", 3, "avewind", "mph", "mph", + "mdi:weather-windy"), + 'precip_1d_mm': WUDailySimpleForecastSensorConfig( + "Precipitation Intensity Today", 0, 'qpf_allday', 'mm', 'mm', + "mdi:umbrella"), + 'precip_2d_mm': WUDailySimpleForecastSensorConfig( + "Precipitation Intensity Tomorrow", 1, 'qpf_allday', 'mm', 'mm', + "mdi:umbrella"), + 'precip_3d_mm': WUDailySimpleForecastSensorConfig( + "Precipitation Intensity in 3 Days", 2, 'qpf_allday', 'mm', 'mm', + "mdi:umbrella"), + 'precip_4d_mm': WUDailySimpleForecastSensorConfig( + "Precipitation Intensity in 4 Days", 3, 'qpf_allday', 'mm', 'mm', + "mdi:umbrella"), + 'precip_1d_in': WUDailySimpleForecastSensorConfig( + "Precipitation Intensity Today", 0, 'qpf_allday', 'in', + LENGTH_INCHES, "mdi:umbrella"), + 'precip_2d_in': WUDailySimpleForecastSensorConfig( + "Precipitation Intensity Tomorrow", 1, 'qpf_allday', 'in', + LENGTH_INCHES, "mdi:umbrella"), + 'precip_3d_in': WUDailySimpleForecastSensorConfig( + "Precipitation Intensity in 3 Days", 2, 'qpf_allday', 'in', + LENGTH_INCHES, "mdi:umbrella"), + 'precip_4d_in': WUDailySimpleForecastSensorConfig( + "Precipitation Intensity in 4 Days", 3, 'qpf_allday', 'in', + LENGTH_INCHES, "mdi:umbrella"), + 'precip_1d': WUDailySimpleForecastSensorConfig( + "Percipitation Probability Today", 0, "pop", None, "%", + "mdi:umbrella"), + 'precip_2d': WUDailySimpleForecastSensorConfig( + "Percipitation Probability Tomorrow", 1, "pop", None, "%", + "mdi:umbrella"), + 'precip_3d': WUDailySimpleForecastSensorConfig( + "Percipitation Probability in 3 Days", 2, "pop", None, "%", + "mdi:umbrella"), + 'precip_4d': WUDailySimpleForecastSensorConfig( + "Percipitation Probability in 4 Days", 3, "pop", None, "%", + "mdi:umbrella"), } # Alert Attributes @@ -105,9 +617,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_PWS_ID): cv.string, vol.Optional(CONF_LANG, default=DEFAULT_LANG): - vol.All(vol.In(LANG_CODES)), + vol.All(vol.In(LANG_CODES)), vol.Required(CONF_MONITORED_CONDITIONS, default=[]): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) @@ -138,6 +650,20 @@ class WUndergroundSensor(Entity): """Initialize the sensor.""" self.rest = rest self._condition = condition + self.rest.request_feature(SENSOR_TYPES[condition].feature) + + def _cfg_expand(self, what, default=None): + cfg = SENSOR_TYPES[self._condition] + val = getattr(cfg, what) + try: + val = val(self.rest) + except (KeyError, IndexError) as err: + _LOGGER.error("Failed to parse response from WU API: %s", err) + val = default + except TypeError: + pass # val was not callable - keep original value + + return val @property def name(self): @@ -147,69 +673,42 @@ class WUndergroundSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if self.rest.data: - - if self._condition == 'elevation' and self._condition in \ - self.rest.data['observation_location']: - return self.rest.data['observation_location'][self._condition]\ - .split()[0] - - if self._condition == 'location' and \ - 'full' in self.rest.data['display_location']: - return self.rest.data['display_location']['full'] - - if self._condition in self.rest.data: - if self._condition == 'relative_humidity': - return int(self.rest.data[self._condition][:-1]) - else: - return self.rest.data[self._condition] - - if self._condition == 'alerts': - if self.rest.alerts: - return len(self.rest.alerts) - else: - return 0 - return STATE_UNKNOWN + return self._cfg_expand("value", STATE_UNKNOWN) @property def device_state_attributes(self): """Return the state attributes.""" - attrs = {} + attrs = self._cfg_expand("device_state_attributes", {}) + for (attr, callback) in attrs.items(): + try: + attrs[attr] = callback(self.rest) + except TypeError: + attrs[attr] = callback attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - - if not self.rest.alerts or self._condition != 'alerts': - return attrs - - multiple_alerts = len(self.rest.alerts) > 1 - for data in self.rest.alerts: - for alert in ALERTS_ATTRS: - if data[alert]: - if multiple_alerts: - dkey = alert.capitalize() + '_' + data['type'] - else: - dkey = alert.capitalize() - attrs[dkey] = data[alert] + attrs[ATTR_FRIENDLY_NAME] = self._cfg_expand("friendly_name") return attrs + @property + def icon(self): + """Return icon.""" + return self._cfg_expand("icon", super().icon) + @property def entity_picture(self): """Return the entity picture.""" - if self.rest.data and self._condition == 'weather': - url = self.rest.data['icon_url'] + url = self._cfg_expand("entity_picture") + if url is not None: return re.sub(r'^http://', 'https://', url, flags=re.IGNORECASE) @property def unit_of_measurement(self): """Return the units of measurement.""" - return SENSOR_TYPES[self._condition][1] + return self._cfg_expand("unit_of_measurement") def update(self): """Update current conditions.""" - if self._condition == 'alerts': - self.rest.update_alerts() - else: - self.rest.update() + self.rest.update() class WUndergroundData(object): @@ -223,11 +722,16 @@ class WUndergroundData(object): self._lang = 'lang:{}'.format(lang) self._latitude = hass.config.latitude self._longitude = hass.config.longitude + self._features = set() self.data = None - self.alerts = None + + def request_feature(self, feature): + """Register feature to be fetched from WU API.""" + self._features.add(feature) def _build_url(self, baseurl=_RESOURCE): - url = baseurl.format(self._api_key, self._lang) + url = baseurl.format( + self._api_key, "/".join(self._features), self._lang) if self._pws_id: url = url + 'pws:{}'.format(self._pws_id) else: @@ -235,7 +739,7 @@ class WUndergroundData(object): return url + '.json' - @Throttle(MIN_TIME_BETWEEN_UPDATES_OBSERVATION) + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from WUnderground.""" try: @@ -244,21 +748,7 @@ class WUndergroundData(object): raise ValueError(result['response']["error"] ["description"]) else: - self.data = result["current_observation"] + self.data = result except ValueError as err: _LOGGER.error("Check WUnderground API %s", err.args) self.data = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES_ALERTS) - def update_alerts(self): - """Get the latest alerts data from WUnderground.""" - try: - result = requests.get(self._build_url(_ALERTS), timeout=10).json() - if "error" in result['response']: - raise ValueError(result['response']["error"] - ["description"]) - else: - self.alerts = result["alerts"] - except ValueError as err: - _LOGGER.error("Check WUnderground API %s", err.args) - self.alerts = None diff --git a/tests/components/sensor/test_wunderground.py b/tests/components/sensor/test_wunderground.py index 286f9d959e2..1a3c0304b00 100644 --- a/tests/components/sensor/test_wunderground.py +++ b/tests/components/sensor/test_wunderground.py @@ -2,7 +2,7 @@ import unittest from homeassistant.components.sensor import wunderground -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, LENGTH_INCHES from tests.common import get_test_home_assistant @@ -19,7 +19,8 @@ VALID_CONFIG = { 'platform': 'wunderground', 'api_key': 'foo', 'monitored_conditions': [ - 'weather', 'feelslike_c', 'alerts', 'elevation', 'location' + 'weather', 'feelslike_c', 'alerts', 'elevation', 'location', + 'weather_1d_metric', 'precip_1d_in' ] } @@ -37,6 +38,8 @@ FEELS_LIKE = '40' WEATHER = 'Clear' HTTPS_ICON_URL = 'https://icons.wxug.com/i/c/k/clear.gif' ALERT_MESSAGE = 'This is a test alert message' +FORECAST_TEXT = 'Mostly Cloudy. Fog overnight.' +PRECIP_IN = 0.03 def mocked_requests_get(*args, **kwargs): @@ -60,7 +63,9 @@ def mocked_requests_get(*args, **kwargs): "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", "features": { - "conditions": 1 + "conditions": 1, + "alerts": 1, + "forecast": 1, } }, "current_observation": { "image": { @@ -90,7 +95,58 @@ def mocked_requests_get(*args, **kwargs): "message": ALERT_MESSAGE, }, - ], + ], "forecast": { + "txt_forecast": { + "date": "22:35 CEST", + "forecastday": [ + { + "period": 0, + "icon_url": + "http://icons.wxug.com/i/c/k/clear.gif", + "title": "Tuesday", + "fcttext": FORECAST_TEXT, + "fcttext_metric": FORECAST_TEXT, + "pop": "0" + }, + ], + }, "simpleforecast": { + "forecastday": [ + { + "date": { + "pretty": "19:00 CEST 4. Duben 2017", + }, + "period": 1, + "high": { + "fahrenheit": "56", + "celsius": "13", + }, + "low": { + "fahrenheit": "43", + "celsius": "6", + }, + "conditions": "Možnost deště", + "icon_url": + "http://icons.wxug.com/i/c/k/chancerain.gif", + "qpf_allday": { + "in": PRECIP_IN, + "mm": 1, + }, + "maxwind": { + "mph": 0, + "kph": 0, + "dir": "", + "degrees": 0, + }, + "avewind": { + "mph": 0, + "kph": 0, + "dir": "severní", + "degrees": 0 + } + }, + ], + }, + }, }, 200) else: return MockResponse({ @@ -168,7 +224,13 @@ class TestWundergroundSetup(unittest.TestCase): self.assertEqual('Holly Springs, NC', device.state) elif device.name == 'PWS_elevation': self.assertEqual('413', device.state) - else: + elif device.name == 'PWS_feelslike_c': self.assertIsNone(device.entity_picture) self.assertEqual(FEELS_LIKE, device.state) self.assertEqual(TEMP_CELSIUS, device.unit_of_measurement) + elif device.name == 'PWS_weather_1d_metric': + self.assertEqual(FORECAST_TEXT, device.state) + else: + self.assertEqual(device.name, 'PWS_precip_1d_in') + self.assertEqual(PRECIP_IN, device.state) + self.assertEqual(LENGTH_INCHES, device.unit_of_measurement) From 2c1df75c07d039da5704714a3081219104a6c92e Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 7 May 2017 03:22:38 +0300 Subject: [PATCH 029/135] Switch russound, pymysensors, and pocketcasts to pypi (#7449) * Switch russound to pypi * Switch pymysensors to pypi * Switch pocketcasts to pypi --- .../components/media_player/russound_rnet.py | 4 +--- homeassistant/components/mysensors.py | 4 +--- homeassistant/components/sensor/pocketcasts.py | 4 +--- requirements_all.txt | 18 +++++++++--------- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/media_player/russound_rnet.py b/homeassistant/components/media_player/russound_rnet.py index 6615f85db65..9ce3dcfc4f4 100644 --- a/homeassistant/components/media_player/russound_rnet.py +++ b/homeassistant/components/media_player/russound_rnet.py @@ -15,9 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON, CONF_NAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = [ - 'https://github.com/laf/russound/archive/0.1.7.zip' - '#russound==0.1.7'] +REQUIREMENTS = ['russound==0.1.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 984ff8a4606..761885c9905 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -21,9 +21,7 @@ from homeassistant.const import ( from homeassistant.helpers import discovery from homeassistant.loader import get_component -REQUIREMENTS = [ - 'https://github.com/theolind/pymysensors/archive/' - 'c6990eaaa741444a638608e6e00488195e2ca74c.zip#pymysensors==0.9.1'] +REQUIREMENTS = ['pymysensors==0.9.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/pocketcasts.py b/homeassistant/components/sensor/pocketcasts.py index 36e0bb88e0a..20b1c9885cc 100644 --- a/homeassistant/components/sensor/pocketcasts.py +++ b/homeassistant/components/sensor/pocketcasts.py @@ -17,9 +17,7 @@ from homeassistant.components.sensor import (PLATFORM_SCHEMA) _LOGGER = logging.getLogger(__name__) -COMMIT = '9f61ff00c77c7c98ffa0af9dd3540df3dce4a836' -REQUIREMENTS = ['https://github.com/molobrakos/python-pocketcasts/' - 'archive/%s.zip#python-pocketcasts==0.0.1' % COMMIT] +REQUIREMENTS = ['pocketcasts==0.1'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, diff --git a/requirements_all.txt b/requirements_all.txt index f8b6cb1f125..26f37a3cac9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,15 +291,9 @@ https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad591540925 # homeassistant.components.media_player.nad https://github.com/joopert/nad_receiver/archive/0.0.3.zip#nad_receiver==0.0.3 -# homeassistant.components.media_player.russound_rnet -https://github.com/laf/russound/archive/0.1.7.zip#russound==0.1.7 - # homeassistant.components.media_player.onkyo https://github.com/miracle2k/onkyo-eiscp/archive/066023aec04770518d494c32fb72eea0ec5c1b7c.zip#onkyo-eiscp==1.0 -# homeassistant.components.sensor.pocketcasts -https://github.com/molobrakos/python-pocketcasts/archive/9f61ff00c77c7c98ffa0af9dd3540df3dce4a836.zip#python-pocketcasts==0.0.1 - # homeassistant.components.switch.anel_pwrctrl https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1 @@ -321,9 +315,6 @@ https://github.com/tfriedel/python-lightify/archive/1bb1db0e7bd5b14304d7bb267e23 # homeassistant.components.lutron https://github.com/thecynic/pylutron/archive/v0.1.0.zip#pylutron==0.1.0 -# homeassistant.components.mysensors -https://github.com/theolind/pymysensors/archive/c6990eaaa741444a638608e6e00488195e2ca74c.zip#pymysensors==0.9.1 - # homeassistant.components.sensor.modem_callerid https://github.com/vroomfonde1/basicmodem/archive/0.7.zip#basicmodem==0.7 @@ -461,6 +452,9 @@ plexapi==2.0.2 # homeassistant.components.sensor.serial_pm pmsensor==0.4 +# homeassistant.components.sensor.pocketcasts +pocketcasts==0.1 + # homeassistant.components.climate.proliphix proliphix==0.4.1 @@ -596,6 +590,9 @@ pymailgunner==1.4 # homeassistant.components.mochad pymochad==0.1.1 +# homeassistant.components.mysensors +pymysensors==0.9.1 + # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 @@ -739,6 +736,9 @@ ring_doorbell==0.1.4 # homeassistant.components.switch.rpi_rf # rpi-rf==0.9.6 +# homeassistant.components.media_player.russound_rnet +russound==0.1.7 + # homeassistant.components.media_player.yamaha rxv==0.4.0 From 47034f83f423acf154b76c5c32748d537fcd6457 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 7 May 2017 04:10:17 +0200 Subject: [PATCH 030/135] Upgrade pymysensors to 0.10.0 (#7469) --- homeassistant/components/mysensors.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 761885c9905..ef863bfb34f 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.helpers import discovery from homeassistant.loader import get_component -REQUIREMENTS = ['pymysensors==0.9.1'] +REQUIREMENTS = ['pymysensors==0.10.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 26f37a3cac9..ace8b33db7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -591,7 +591,7 @@ pymailgunner==1.4 pymochad==0.1.1 # homeassistant.components.mysensors -pymysensors==0.9.1 +pymysensors==0.10.0 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 From e8a33758c103965f11a283a8ced2b03256ce2ea7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 6 May 2017 19:38:48 -0700 Subject: [PATCH 031/135] Capitalize group names in demo --- homeassistant/components/demo.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index f926d059db2..135bf696fc6 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -157,24 +157,24 @@ def async_setup(hass, config): }}, ]})) - tasks2.append(group.Group.async_create_group(hass, 'living room', [ + tasks2.append(group.Group.async_create_group(hass, 'Living Room', [ lights[1], switches[0], 'input_select.living_room_preset', 'cover.living_room_window', media_players[1], 'scene.romantic_lights'])) - tasks2.append(group.Group.async_create_group(hass, 'bedroom', [ + tasks2.append(group.Group.async_create_group(hass, 'Bedroom', [ lights[0], switches[1], media_players[0], 'input_slider.noise_allowance'])) - tasks2.append(group.Group.async_create_group(hass, 'kitchen', [ + tasks2.append(group.Group.async_create_group(hass, 'Kitchen', [ lights[2], 'cover.kitchen_window', 'lock.kitchen_door'])) - tasks2.append(group.Group.async_create_group(hass, 'doors', [ + tasks2.append(group.Group.async_create_group(hass, 'Doors', [ 'lock.front_door', 'lock.kitchen_door', 'garage_door.right_garage_door', 'garage_door.left_garage_door'])) - tasks2.append(group.Group.async_create_group(hass, 'automations', [ + tasks2.append(group.Group.async_create_group(hass, 'Automations', [ 'input_select.who_cooks', 'input_boolean.notify', ])) - tasks2.append(group.Group.async_create_group(hass, 'people', [ + tasks2.append(group.Group.async_create_group(hass, 'People', [ 'device_tracker.demo_anne_therese', 'device_tracker.demo_home_boy', 'device_tracker.demo_paulus'])) - tasks2.append(group.Group.async_create_group(hass, 'downstairs', [ + tasks2.append(group.Group.async_create_group(hass, 'Downstairs', [ 'group.living_room', 'group.kitchen', 'scene.romantic_lights', 'cover.kitchen_window', 'cover.living_room_window', 'group.doors', From ea095de98e0226cab4a255b68788947671038ee1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 6 May 2017 19:40:59 -0700 Subject: [PATCH 032/135] Demo: Update old group member thermostat.ecobee -> climate --- homeassistant/components/demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 135bf696fc6..f77a5f05f62 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -178,7 +178,7 @@ def async_setup(hass, config): 'group.living_room', 'group.kitchen', 'scene.romantic_lights', 'cover.kitchen_window', 'cover.living_room_window', 'group.doors', - 'thermostat.ecobee', + 'climate.ecobee', ], view=True)) results = yield from asyncio.gather(*tasks2, loop=hass.loop) From 305309a59e399677b888ab5bed8fbe6cf3507012 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 6 May 2017 20:16:40 -0700 Subject: [PATCH 033/135] Upgrade Dockerfile to Python 3.6 (#7471) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f0ceb982982..54f993b01a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.5 +FROM python:3.6 MAINTAINER Paulus Schoutsen # Uncomment any of the following lines to disable the installation. From aa6339818e0deebe813c109050ae78ff546e7e95 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 6 May 2017 22:37:31 -0700 Subject: [PATCH 034/135] Test only dependencies (#7472) * Generate requirements file for tests * Update tox * Update validate * Lint * Tweak order in travis.yml to run longest job first --- .travis.yml | 8 +- requirements_test_all.txt | 161 +++++++++++++++++++++++++++++++++ script/gen_requirements_all.py | 121 ++++++++++++++++++++++--- tox.ini | 6 +- 4 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 requirements_test_all.txt diff --git a/.travis.yml b/.travis.yml index aad5cc7028a..32060472e9f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,12 +6,10 @@ addons: matrix: fast_finish: true include: - - python: "3.4.2" - env: TOXENV=py34 - - python: "3.4.2" - env: TOXENV=requirements - python: "3.4.2" env: TOXENV=lint + - python: "3.4.2" + env: TOXENV=py34 # - python: "3.5" # env: TOXENV=typing - python: "3.5" @@ -20,6 +18,8 @@ matrix: env: TOXENV=py36 - python: "3.6-dev" env: TOXENV=py36 + - python: "3.4.2" + env: TOXENV=requirements # allow_failures: # - python: "3.5" # env: TOXENV=typing diff --git a/requirements_test_all.txt b/requirements_test_all.txt new file mode 100644 index 00000000000..25b5b09831c --- /dev/null +++ b/requirements_test_all.txt @@ -0,0 +1,161 @@ +# Home Assistant test +# linters such as flake8 and pylint should be pinned, as new releases +# make new things fail. Manually update these pins when pulling in a +# new version +flake8==3.3 +pylint==1.6.5 +mypy==0.501 +pydocstyle==1.1.1 +coveralls>=1.1 +pytest>=2.9.2 +pytest-aiohttp>=0.1.3 +pytest-asyncio>=0.5.0 +pytest-cov>=2.3.1 +pytest-timeout>=1.2.0 +pytest-catchlog>=1.2.2 +pytest-sugar>=0.7.1 +requests_mock>=1.0 +mock-open>=1.3.1 +flake8-docstrings==1.0.2 +asynctest>=0.8.0 +freezegun>=0.3.8 + + +# homeassistant.components.notify.html5 +PyJWT==1.4.2 + +# homeassistant.components.media_player.sonos +SoCo==0.12 + +# homeassistant.components.device_tracker.automatic +aioautomatic==0.3.1 + +# homeassistant.components.emulated_hue +# homeassistant.components.http +aiohttp_cors==0.5.3 + +# homeassistant.components.notify.apns +apns2==0.1.1 + +# homeassistant.components.sun +# homeassistant.components.sensor.moon +astral==1.4 + +# homeassistant.components.datadog +datadog==0.15.0 + +# homeassistant.components.sensor.dsmr +dsmr_parser==0.8 + +# homeassistant.components.climate.honeywell +evohomeclient==0.2.5 + +# homeassistant.components.media_player.frontier_silicon +fsapi==0.0.7 + +# homeassistant.components.conversation +fuzzywuzzy==0.15.0 + +# homeassistant.components.tts.google +gTTS-token==1.1.1 + +# homeassistant.components.ffmpeg +ha-ffmpeg==1.5 + +# homeassistant.components.mqtt.server +hbmqtt==0.8 + +# homeassistant.components.binary_sensor.workday +holidays==0.8.1 + +# homeassistant.components.influxdb +# homeassistant.components.sensor.influxdb +influxdb==3.0.0 + +# homeassistant.components.media_player.soundtouch +libsoundtouch==0.3.0 + +# homeassistant.components.sensor.mfi +# homeassistant.components.switch.mfi +mficlient==0.3.0 + +# homeassistant.components.tts +mutagen==1.37.0 + +# homeassistant.components.discovery +netdisco==1.0.0rc3 + +# homeassistant.components.mqtt +paho-mqtt==1.2.3 + +# homeassistant.components.device_tracker.aruba +# homeassistant.components.device_tracker.asuswrt +# homeassistant.components.device_tracker.cisco_ios +# homeassistant.components.media_player.pandora +pexpect==4.0.1 + +# homeassistant.components.pilight +pilight==0.1.1 + +# homeassistant.components.sensor.mhz19 +# homeassistant.components.sensor.serial_pm +pmsensor==0.4 + +# homeassistant.components.media_player.cast +pychromecast==0.8.1 + +# homeassistant.components.media_player.cmus +pycmus==0.1.0 + +# homeassistant.components.zwave +pydispatcher==2.0.5 + +# homeassistant.components.notify.html5 +pyelliptic==1.5.7 + +# homeassistant.components.litejet +pylitejet==0.1 + +# homeassistant.components.mochad +pymochad==0.1.1 + +# homeassistant.components.alarm_control_panel.nx584 +# homeassistant.components.binary_sensor.nx584 +pynx584==0.4 + +# homeassistant.components.sensor.darksky +python-forecastio==1.3.5 + +# homeassistant.components.device_tracker.unifi +pyunifi==2.0 + +# homeassistant.components.notify.html5 +pywebpush==0.6.1 + +# homeassistant.components.rflink +rflink==0.0.31 + +# homeassistant.components.ring +ring_doorbell==0.1.4 + +# homeassistant.components.media_player.yamaha +rxv==0.4.0 + +# homeassistant.components.sleepiq +sleepyq==0.6 + +# homeassistant.components.climate.honeywell +somecomfort==0.4.1 + +# homeassistant.components.recorder +# homeassistant.scripts.db_migrator +sqlalchemy==1.1.9 + +# homeassistant.components.statsd +statsd==3.2.1 + +# homeassistant.components.camera.uvc +uvcclient==0.10.0 + +# homeassistant.components.sensor.yahoo_finance +yahoo-finance==1.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b6424fc5fdd..fd63436dfd0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -28,6 +28,53 @@ COMMENT_REQUIREMENTS = ( 'face_recognition' ) +TEST_REQUIREMENTS = ( + 'pydispatch', + 'influxdb', + 'nx584', + 'uvcclient', + 'somecomfort', + 'aioautomatic', + 'pyunifi', + 'SoCo', + 'libsoundtouch', + 'rxv', + 'apns2', + 'sqlalchemy', + 'forecastio', + 'astral', + 'aiohttp_cors', + 'pilight', + 'fuzzywuzzy', + 'datadog', + 'netdisco', + 'rflink', + 'ring_doorbell', + 'sleepyq', + 'statsd', + 'pylitejet', + 'holidays', + 'evohomeclient', + 'pexpect', + 'hbmqtt', + 'pychromecast', + 'pycmus', + 'fsapi', + 'paho', + 'jwt', + 'dsmr_parser', + 'mficlient', + 'pmsensor', + 'yahoo-finance', + 'pymochad', + 'mutagen', + 'ha-ffmpeg', + 'gTTS-token', + 'pywebpush', + 'pyelliptic', + 'PyJWT', +) + IGNORE_PACKAGES = ( 'homeassistant.components.recorder.models', ) @@ -78,11 +125,10 @@ def comment_requirement(req): def gather_modules(): - """Collect the information and construct the output.""" + """Collect the information.""" reqs = {} errors = [] - output = [] for package in sorted(explore_module('homeassistant.components', True) + explore_module('homeassistant.scripts', True)): @@ -115,10 +161,12 @@ def gather_modules(): print("Make sure you import 3rd party libraries inside methods.") return None - output.append('# Home Assistant core') - output.append('\n') - output.append('\n'.join(core_requirements())) - output.append('\n') + return reqs + + +def generate_requirements_list(reqs): + """Generate a pip file based on requirements.""" + output = [] for pkg, requirements in sorted(reqs.items(), key=lambda item: item[0]): for req in sorted(requirements, key=lambda name: (len(name.split('.')), name)): @@ -128,6 +176,32 @@ def gather_modules(): output.append('\n# {}\n'.format(pkg)) else: output.append('\n{}\n'.format(pkg)) + return ''.join(output) + + +def requirements_all_output(reqs): + """Generate output for requirements_all.""" + output = [] + output.append('# Home Assistant core') + output.append('\n') + output.append('\n'.join(core_requirements())) + output.append('\n') + output.append(generate_requirements_list(reqs)) + + return ''.join(output) + + +def requirements_test_output(reqs): + """Generate output for test_requirements.""" + output = [] + output.append('# Home Assistant test') + output.append('\n') + with open('requirements_test.txt') as fp: + output.append(fp.read()) + output.append('\n') + filtered = {key: value for key, value in reqs.items() + if any(ign in key for ign in TEST_REQUIREMENTS)} + output.append(generate_requirements_list(filtered)) return ''.join(output) @@ -143,6 +217,12 @@ def write_requirements_file(data): req_file.write(data) +def write_test_requirements_file(data): + """Write the modules to the requirements_all.txt.""" + with open('requirements_test_all.txt', 'w+', newline="\n") as req_file: + req_file.write(data) + + def write_constraints_file(data): """Write constraints to a file.""" with open(CONSTRAINT_PATH, 'w+', newline="\n") as req_file: @@ -155,6 +235,12 @@ def validate_requirements_file(data): return data == ''.join(req_file) +def validate_requirements_test_file(data): + """Validate if requirements_all.txt is up to date.""" + with open('requirements_test_all.txt', 'r') as req_file: + return data == ''.join(req_file) + + def validate_constraints_file(data): """Validate if constraints is up to date.""" with open(CONSTRAINT_PATH, 'r') as req_file: @@ -174,22 +260,31 @@ def main(): constraints = gather_constraints() + reqs_file = requirements_all_output(data) + reqs_test_file = requirements_test_output(data) + if sys.argv[-1] == 'validate': - if not validate_requirements_file(data): - print("******* ERROR") - print("requirements_all.txt is not up to date") - print("Please run script/gen_requirements_all.py") - sys.exit(1) + errors = [] + if not validate_requirements_file(reqs_file): + errors.append("requirements_all.txt is not up to date") + + if not validate_requirements_test_file(reqs_test_file): + errors.append("requirements_test_all.txt is not up to date") if not validate_constraints_file(constraints): + errors.append( + "home-assistant/package_constraints.txt is not up to date") + + if errors: print("******* ERROR") - print("home-assistant/package_constraints.txt is not up to date") + print('\n'.join(errors)) print("Please run script/gen_requirements_all.py") sys.exit(1) sys.exit(0) - write_requirements_file(data) + write_requirements_file(reqs_file) + write_test_requirements_file(reqs_test_file) write_constraints_file(constraints) diff --git a/tox.ini b/tox.ini index a1a04bd2ea7..aede0246209 100644 --- a/tox.ini +++ b/tox.ini @@ -14,12 +14,14 @@ install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = py.test --timeout=30 --duration=10 --cov --cov-report= {posargs} deps = - -r{toxinidir}/requirements_all.txt - -r{toxinidir}/requirements_test.txt + -r{toxinidir}/requirements_test_all.txt [testenv:lint] basepython = python3 ignore_errors = True +deps = + -r{toxinidir}/requirements_all.txt + -r{toxinidir}/requirements_test.txt commands = flake8 pylint homeassistant From 41212b90c481d518a75eb62ef4cd350f45bf4698 Mon Sep 17 00:00:00 2001 From: Caleb Date: Sun, 7 May 2017 00:39:21 -0500 Subject: [PATCH 035/135] Update to pyunifi 2.12 (#7468) * Update to pyunifi 2.12 * Update requirements_all.txt --- homeassistant/components/device_tracker/unifi.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 42b5070b046..b0409e99883 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.const import CONF_VERIFY_SSL -REQUIREMENTS = ['pyunifi==2.0'] +REQUIREMENTS = ['pyunifi==2.12'] _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' diff --git a/requirements_all.txt b/requirements_all.txt index ace8b33db7a..215e39e373e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -701,7 +701,7 @@ pytrackr==0.0.5 pytradfri==1.1 # homeassistant.components.device_tracker.unifi -pyunifi==2.0 +pyunifi==2.12 # homeassistant.components.keyboard # pyuserinput==0.1.11 From 79ca47640e9fa0158244a425804a19d3890a38ef Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 6 May 2017 23:02:12 -0700 Subject: [PATCH 036/135] Update requirements_test_all.txt --- requirements_test_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25b5b09831c..bd7a9edbee9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -127,7 +127,7 @@ pynx584==0.4 python-forecastio==1.3.5 # homeassistant.components.device_tracker.unifi -pyunifi==2.0 +pyunifi==2.12 # homeassistant.components.notify.html5 pywebpush==0.6.1 From c525ee9daaefe580154eaade5edce87908d63bb8 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 May 2017 23:11:11 -0700 Subject: [PATCH 037/135] Make this an error instead of an info --- homeassistant/components/zwave/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 19a813c1ed6..4033e195be0 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -420,10 +420,10 @@ def setup(hass, config): node.set_config_param(param, selection, size) else: if selection > i: - _LOGGER.info("Config parameter selection does not exist! " - "Please check zwcfg_[home_id].xml in " - "your homeassistant config directory. " - "Available selections are 0 to %s", i) + _LOGGER.error("Config parameter selection does not exist! " + "Please check zwcfg_[home_id].xml in " + "your homeassistant config directory. " + "Available selections are 0 to %s", i) return node.set_config_param(param, selection, size) _LOGGER.info("Setting config parameter %s on Node %s " From 9440ff881f7723f955089769f3085111dcfd5757 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 6 May 2017 23:52:39 -0700 Subject: [PATCH 038/135] Remove listening to homeassistant_start with event automation (#7474) --- homeassistant/components/automation/event.py | 17 ++-------- tests/components/automation/test_event.py | 35 ++------------------ 2 files changed, 5 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index ba8e67e9213..32d2d245bef 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -9,8 +9,8 @@ import logging import voluptuous as vol -from homeassistant.core import callback, CoreState -from homeassistant.const import CONF_PLATFORM, EVENT_HOMEASSISTANT_START +from homeassistant.core import callback +from homeassistant.const import CONF_PLATFORM from homeassistant.helpers import config_validation as cv CONF_EVENT_TYPE = 'event_type' @@ -31,19 +31,6 @@ def async_trigger(hass, config, action): event_type = config.get(CONF_EVENT_TYPE) event_data = config.get(CONF_EVENT_DATA) - if (event_type == EVENT_HOMEASSISTANT_START and - hass.state == CoreState.starting): - _LOGGER.warning('Deprecation: Automations should not listen to event ' - "'homeassistant_start'. Use platform 'homeassistant' " - 'instead. Feature will be removed in 0.45') - hass.async_run_job(action, { - 'trigger': { - 'platform': 'event', - 'event': None, - }, - }) - return lambda: None - @callback def handle_event(event): """Listen for events and calls the action when data matches.""" diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index a056520a5c9..b4686650057 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -1,13 +1,11 @@ """The tests for the Event automation.""" -import asyncio import unittest -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import callback, CoreState -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.core import callback +from homeassistant.setup import setup_component import homeassistant.components.automation as automation -from tests.common import get_test_home_assistant, mock_component, mock_service +from tests.common import get_test_home_assistant, mock_component # pylint: disable=invalid-name @@ -94,30 +92,3 @@ class TestAutomationEvent(unittest.TestCase): self.hass.bus.fire('test_event', {'some_attr': 'some_other_value'}) self.hass.block_till_done() self.assertEqual(0, len(self.calls)) - - -@asyncio.coroutine -def test_if_fires_on_event_with_data(hass): - """Test the firing of events with data.""" - calls = mock_service(hass, 'test', 'automation') - hass.state = CoreState.not_running - - res = yield from async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'alias': 'hello', - 'trigger': { - 'platform': 'event', - 'event_type': EVENT_HOMEASSISTANT_START, - }, - 'action': { - 'service': 'test.automation', - } - } - }) - assert res - assert not automation.is_on(hass, 'automation.hello') - assert len(calls) == 0 - - yield from hass.async_start() - assert automation.is_on(hass, 'automation.hello') - assert len(calls) == 1 From c1056ea4d4c401bb4544b5d8833b51459cafdd7a Mon Sep 17 00:00:00 2001 From: Marc Egli Date: Sun, 7 May 2017 15:15:18 +0200 Subject: [PATCH 039/135] Fix plant MIN_TEMPERATURE, MAX_TEMPERATURE validation (#7476) * Fix plant MIN_TEMPERATURE, MAX_TEMPERATURE validation small_float only allows values from 0 to 1 so we should use float instead * Do not use vol.All for a single validation --- homeassistant/components/plant.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 2215d7c2f30..2070c22fb97 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -58,8 +58,8 @@ SCHEMA_SENSORS = vol.Schema({ PLANT_SCHEMA = vol.Schema({ vol.Required(CONF_SENSORS): vol.Schema(SCHEMA_SENSORS), vol.Optional(CONF_MIN_BATTERY_LEVEL): cv.positive_int, - vol.Optional(CONF_MIN_TEMPERATURE): cv.small_float, - vol.Optional(CONF_MAX_TEMPERATURE): cv.small_float, + vol.Optional(CONF_MIN_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMPERATURE): vol.Coerce(float), vol.Optional(CONF_MIN_MOISTURE): cv.positive_int, vol.Optional(CONF_MAX_MOISTURE): cv.positive_int, vol.Optional(CONF_MIN_CONDUCTIVITY): cv.positive_int, From 00ec50da4be3b442d9b2ce33cd31c5d3f7ee9ddf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 7 May 2017 13:50:07 -0700 Subject: [PATCH 040/135] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-hassio.html | 4 ++-- .../www_static/panels/ha-panel-hassio.html.gz | Bin 7451 -> 7383 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2513 -> 2513 bytes 6 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 943074beb40..0d649344862 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -12,7 +12,7 @@ FINGERPRINTS = { "panels/ha-panel-dev-service.html": "415552027cb083badeff5f16080410ed", "panels/ha-panel-dev-state.html": "d70314913b8923d750932367b1099750", "panels/ha-panel-dev-template.html": "567fbf86735e1b891e40c2f4060fec9b", - "panels/ha-panel-hassio.html": "333f86e5f516b31e52365e412deb7fdc", + "panels/ha-panel-hassio.html": "23d175b6744c20e2fdf475b6efdaa1d3", "panels/ha-panel-history.html": "89062c48c76206cad1cec14ddbb1cbb1", "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab", "panels/ha-panel-logbook.html": "6dd6a16f52117318b202e60f98400163", diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index f020e60b67e..9e7dc4a921f 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit f020e60b67ec38e3ede72b1ebc86d4e055565cd7 +Subproject commit 9e7dc4a921f86e60cc1f14afe254e5310b63e854 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html index c82bfcb54e8..80d1686acf0 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html @@ -12,6 +12,6 @@ }, computeInstallStatus(addon) { - return addon.installed || 'Not installed'; + return (addon && addon.installed) || 'Not installed'; }, -}); \ No newline at end of file +}); \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz index c7689872442293fb47f9500bae9c7482f0077000..5ed1205a99958263848ffedc2f48e134b50c3172 100644 GIT binary patch literal 7383 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 J&=&H9006*(RyzOy literal 7451 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 delta 1425 zcmV;C1#bG$6VVd~ABzYGYyk|h2ZjTGQL12^YMK=(@;Wof@PegjOcRnJ54VCrs1#XR zP@W5u#kq)csYspzRf*elFL-M;8r@CWMmDyYyzGC*KQ@()tToO?nsC&LM&cd}A*tXj zO%fr5CNj?Gw?zF?&KGtgE0@g3qA-X78b?xUkkJXjI7*U)Bo3_?nK#-lg9`Wug}5C03}uggrf) zi8|F_rT4rbEax@GD7HE;M2iwF*2rH47Fw)rvv-r<*LL|~@;($=|C1yIf`5Q$GYXym z)^1`LY@T1kXSl>cz=3G1{>@QpYD9uHA^_z2XFS)w^X5Pu~t9#1l0!&{bTGy zX#*m&?O?+{5=0#~ebz^$g7Z!L=9`)4I(Ji6sQJ`~Ho`uwy*;~a{66UW z|FgP2Hf6n4HH^`;M=`Mw-J-`}2>lOxvG0X=!suQ%TWWPZ8o#2p!+)Oi$N&eOojC); z8h;3e`h(}fK5bT`W`_j!#i32e;U{E^{yVW}e5l^v2f?(Lhq3d=RU@Vt zw-`GWR?~gUAi5EyeN)@A^7|ch5$3Gg-zC5Er>WyJ+}W7lMk%WIe`s5A8)0j)^dF%a zU5jcBol71E0M>mJpnsbY1{Xm+oCAO_;oocL`#QWs&@}G16jgWSb!hn*PZ#sD_U?B~ z9nLo5gq?%@=2Ku7ZFAG-sGr)lLDV)HMy7Fxllsyd4Z=W=1P+HhHo7V8Vl<>CcZAtw zsh#43JnLfh98eIg)^0bpC-Oad^TdI6&jaY_?o+vXi#{Ca>wgx6X9(bqtJg@ZLk0}o z+#Ze3x@7u)Fy+jdzCOv1`fJX2NBkhzi5-rv_{|>yN>!!n1n^gs%Wpu{GvyE`w8Hz_ zPp`c`8Mgx~qTwXG0TE;SY%cX5*5DSt)(-?zJA3Sff%sF4rKp}AbqUB93~n3@-Et6~ z6A(Rn?cxLG!~+(|&>$ z`>HQXQJ2QaawmL@;^5DJ!iMZ%=Y31E%YE+Tewh9Cv)S2ggw|bY48f=0V(9(vgd7Us zWBGIFOZ4$NbUy58Hyx71CPBPjz^GTLAJTzc2s^qF-G2x^uln9;RCXg|c!wK{yn(8! zx8Ot$a7}bmxo~T1|NCdny3+N(zPBTXH$3R*6Vi8`H*%?Tu;6O+-Sh7c@y1NDxzgE9 z_sl;}{nff#oX8N<;eH6+hlOl&^VGQ*3-=Sli5_^--2Aq8&E;dYE^XyHies3M@7nsi zG7s4f<$pD3Z*!&QZqYWkZtlljr9Ir91z!v7{Tnw-2<|%Tc&q1c1igNZGX&hm5AN?& z)nIZznjTO)G3ng1f%D|Mz^&TLxtsLWGxrBflpAbrF!Y-n@6@$t-?4>aid4a%i>LKp zwr%9JzPt>O)B?Ma8oLpv53_;G4yIA-&NcaBOEP#7^>cnO#T8pKT&TV6RR_Bw_xO!( fzn*0eoq^~bI^(Nuz^{8RV0Hg5e3-=mV-^4an5xvR From 5717c87097d49e969023d6a73cd3ced2e39f886f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 7 May 2017 16:51:46 -0700 Subject: [PATCH 041/135] Update tox.ini --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index aede0246209..ca17de9351b 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,8 @@ setenv = ; utf8 one, tox's env is reset. And the install of these 2 packages ; fail. whitelist_externals = /usr/bin/env -install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} +; TEMP, to get cache going on Travis :-/ +install_command = /usr/bin/env LANG=C.UTF-8 travis_wait pip install {opts} {packages} commands = py.test --timeout=30 --duration=10 --cov --cov-report= {posargs} deps = From bafc04ca4266eb99cc088be113fd1f294d7f0d57 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 7 May 2017 16:53:28 -0700 Subject: [PATCH 042/135] Update tox.ini --- tox.ini | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index ca17de9351b..4a51828f42d 100644 --- a/tox.ini +++ b/tox.ini @@ -9,9 +9,11 @@ setenv = ; which get read in from setup.py. If we don't force our locale to a ; utf8 one, tox's env is reset. And the install of these 2 packages ; fail. -whitelist_externals = /usr/bin/env +whitelist_externals = + /usr/bin/env + /usr/bin/travis_wait ; TEMP, to get cache going on Travis :-/ -install_command = /usr/bin/env LANG=C.UTF-8 travis_wait pip install {opts} {packages} +install_command = /usr/bin/env LANG=C.UTF-8 /usr/bin/travis_wait pip install {opts} {packages} commands = py.test --timeout=30 --duration=10 --cov --cov-report= {posargs} deps = From e1d1385358baa0a2a7d687ca5dd7b447c002e565 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 7 May 2017 16:55:22 -0700 Subject: [PATCH 043/135] Fix travis --- .travis.yml | 2 +- tox.ini | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 32060472e9f..0bdde06bb35 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,5 +29,5 @@ cache: - $HOME/.cache/pip install: pip install -U tox coveralls language: python -script: tox +script: travis_wait tox after_success: coveralls diff --git a/tox.ini b/tox.ini index 4a51828f42d..aede0246209 100644 --- a/tox.ini +++ b/tox.ini @@ -9,11 +9,8 @@ setenv = ; which get read in from setup.py. If we don't force our locale to a ; utf8 one, tox's env is reset. And the install of these 2 packages ; fail. -whitelist_externals = - /usr/bin/env - /usr/bin/travis_wait -; TEMP, to get cache going on Travis :-/ -install_command = /usr/bin/env LANG=C.UTF-8 /usr/bin/travis_wait pip install {opts} {packages} +whitelist_externals = /usr/bin/env +install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = py.test --timeout=30 --duration=10 --cov --cov-report= {posargs} deps = From 66cbdc3043070f3bc8fe7d066a8cf44873d66995 Mon Sep 17 00:00:00 2001 From: Mitesh Patel Date: Sun, 7 May 2017 19:32:13 -0500 Subject: [PATCH 044/135] Uses pypi for deps (#7485) --- homeassistant/components/lutron_caseta.py | 4 +--- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 375e2930a4f..7dd5c647213 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,9 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['https://github.com/gurumitts/' - 'pylutron-caseta/archive/v0.2.6.zip#' - 'pylutron-caseta==v0.2.6'] +REQUIREMENTS = ['pylutron-caseta==0.2.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 215e39e373e..a78285d5b5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,9 +273,6 @@ https://github.com/bah2830/python-roku/archive/3.1.3.zip#roku==3.1.3 # homeassistant.components.modbus https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 -# homeassistant.components.lutron_caseta -https://github.com/gurumitts/pylutron-caseta/archive/v0.2.6.zip#pylutron-caseta==v0.2.6 - # homeassistant.components.media_player.spotify https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f86c90aa26b2.zip#spotipy==2.4.4 @@ -584,6 +581,9 @@ pylitejet==0.1 # homeassistant.components.sensor.loopenergy pyloopenergy==0.0.17 +# homeassistant.components.lutron_caseta +pylutron-caseta==0.2.6 + # homeassistant.components.notify.mailgun pymailgunner==1.4 From 86b34b40a14a5817ec2eb60ad30f7213a1cb951c Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 8 May 2017 16:51:27 +0200 Subject: [PATCH 045/135] LIFX: avoid out-of-bounds hue aborting the colorloop effect (#7495) The hue is now a float but the hsbk conversion still believed it to be an integer that could not be larger than 359. The float can in fact be, for example, 359.9 and this would cause an out-of-bounds error in the set_color call. For completeness, the initial hue is also changed to a float. --- homeassistant/components/light/lifx/effects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index a15360df33e..85de663a817 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -292,7 +292,7 @@ class LIFXEffectColorloop(LIFXEffect): direction = 1 if random.randint(0, 1) else -1 # Random start - hue = random.randint(0, 359) + hue = random.uniform(0, 360) % 360 while self.lights: hue = (hue + direction*change) % 360 @@ -312,7 +312,7 @@ class LIFXEffectColorloop(LIFXEffect): brightness = light.effect_data.color[2] hsbk = [ - int(65535/359*lhue), + int(65535/360*lhue), int(random.uniform(0.8, 1.0)*65535), brightness, NEUTRAL_WHITE, From d7e3962cc03c1bb65d76e7f10fce882e42694ad1 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 8 May 2017 17:02:37 +0200 Subject: [PATCH 046/135] Upgrade async_timeout to 1.2.1 (#7490) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 57fedd7278d..f6774a0d5dc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,5 +6,5 @@ jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.0.7 -async_timeout==1.2.0 +async_timeout==1.2.1 chardet==3.0.2 diff --git a/requirements_all.txt b/requirements_all.txt index a78285d5b5b..ab326c70b48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.0.7 -async_timeout==1.2.0 +async_timeout==1.2.1 chardet==3.0.2 # homeassistant.components.nuimo_controller diff --git a/setup.py b/setup.py index 65d02c3e8c6..d0f4ccbd75b 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ REQUIRES = [ 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.0.7', - 'async_timeout==1.2.0', + 'async_timeout==1.2.1', 'chardet==3.0.2' ] From ce879b7eb866e40155e9cb423d68fd91547cdf91 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 8 May 2017 17:04:17 +0200 Subject: [PATCH 047/135] Prevent printing of packets. (#7492) A small bug in the python-rflink library caused packets to be printed. This update prevents this from happening. --- homeassistant/components/rflink.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index 52fd602f0de..33feb8c034b 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['rflink==0.0.31'] +REQUIREMENTS = ['rflink==0.0.34'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ab326c70b48..734041c8d47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -728,7 +728,7 @@ radiotherm==1.2 # raspihats==2.2.1 # homeassistant.components.rflink -rflink==0.0.31 +rflink==0.0.34 # homeassistant.components.ring ring_doorbell==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd7a9edbee9..72c264fd775 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -133,7 +133,7 @@ pyunifi==2.12 pywebpush==0.6.1 # homeassistant.components.rflink -rflink==0.0.31 +rflink==0.0.34 # homeassistant.components.ring ring_doorbell==0.1.4 From c12c742297caed2866bb0dfeb1577758cea6b3b3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 8 May 2017 19:39:40 +0200 Subject: [PATCH 048/135] Upgrade beautifulsoup4 to 4.6.0 (#7491) --- homeassistant/components/device_tracker/linksys_ap.py | 2 +- homeassistant/components/sensor/scrape.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index a337f71cec4..01f97eb6e42 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -22,7 +22,7 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) INTERFACES = 2 DEFAULT_TIMEOUT = 10 -REQUIREMENTS = ['beautifulsoup4==4.5.3'] +REQUIREMENTS = ['beautifulsoup4==4.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/scrape.py b/homeassistant/components/sensor/scrape.py index f825628d9ae..fe50b567319 100644 --- a/homeassistant/components/sensor/scrape.py +++ b/homeassistant/components/sensor/scrape.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['beautifulsoup4==4.5.3'] +REQUIREMENTS = ['beautifulsoup4==4.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 734041c8d47..8ed766b237d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -81,7 +81,7 @@ batinfo==0.4.2 # homeassistant.components.device_tracker.linksys_ap # homeassistant.components.sensor.scrape -beautifulsoup4==4.5.3 +beautifulsoup4==4.6.0 # homeassistant.components.zha bellows==0.2.7 From 1cd51bc6a8cd9c3fafc5171c88b6e0460155d2a3 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 9 May 2017 08:13:29 +0300 Subject: [PATCH 049/135] Switch onkyo to pypi (#7497) --- homeassistant/components/media_player/onkyo.py | 4 +--- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 18690cca871..19c46b811f7 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -14,9 +14,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import (STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['https://github.com/miracle2k/onkyo-eiscp/archive/' - '066023aec04770518d494c32fb72eea0ec5c1b7c.zip#' - 'onkyo-eiscp==1.0'] +REQUIREMENTS = ['onkyo-eiscp==1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8ed766b237d..8099ec19ee9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -288,9 +288,6 @@ https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad591540925 # homeassistant.components.media_player.nad https://github.com/joopert/nad_receiver/archive/0.0.3.zip#nad_receiver==0.0.3 -# homeassistant.components.media_player.onkyo -https://github.com/miracle2k/onkyo-eiscp/archive/066023aec04770518d494c32fb72eea0ec5c1b7c.zip#onkyo-eiscp==1.0 - # homeassistant.components.switch.anel_pwrctrl https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1 @@ -405,6 +402,9 @@ oauth2client==4.0.0 # homeassistant.components.climate.oem oemthermostat==1.1 +# homeassistant.components.media_player.onkyo +onkyo-eiscp==1.1 + # homeassistant.components.opencv # opencv-python==3.2.0.6 From 419d97fc0654123a5fe2e3305e8f00b7282bfe23 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 9 May 2017 07:24:18 +0200 Subject: [PATCH 050/135] Fixed potential AttributeError when checking for deleted sources (#7502) --- homeassistant/components/media_player/denonavr.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 494a3f69e6d..0a4ec012382 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.4.0'] +REQUIREMENTS = ['denonavr==0.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8099ec19ee9..d1767167ad9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -135,7 +135,7 @@ datapoint==0.4.3 # decora==0.4 # homeassistant.components.media_player.denonavr -denonavr==0.4.0 +denonavr==0.4.1 # homeassistant.components.media_player.directv directpy==0.1 From 40d27cde0edb705bfa742a2f784d7a67b1d16e81 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Tue, 9 May 2017 03:03:34 -0400 Subject: [PATCH 051/135] Refactor sun component for correctness (#7295) * Refactor sun component for correctness * Convert datetimes to dates for astral * Fix tests for updated code * Fix times now that calcs are fixed * Move sun functions to helpers * Fix flake on new file * Additional tweaks from review * Update requirements --- homeassistant/components/automation/sun.py | 2 - .../components/device_sun_light_trigger.py | 26 +- homeassistant/components/sensor/moon.py | 2 - homeassistant/components/sun.py | 300 ++---------- homeassistant/components/switch/flux.py | 12 +- homeassistant/helpers/condition.py | 32 +- homeassistant/helpers/event.py | 36 +- homeassistant/helpers/sun.py | 87 ++++ homeassistant/package_constraints.txt | 1 + requirements_all.txt | 5 +- requirements_test_all.txt | 4 - setup.py | 3 +- tests/common.py | 17 +- tests/components/automation/test_sun.py | 116 +---- tests/components/switch/test_flux.py | 447 +++++++++--------- .../test_device_sun_light_trigger.py | 46 +- tests/components/test_sun.py | 109 ++--- tests/helpers/test_event.py | 38 +- tests/helpers/test_sun.py | 227 +++++++++ 19 files changed, 754 insertions(+), 756 deletions(-) create mode 100644 homeassistant/helpers/sun.py create mode 100644 tests/helpers/test_sun.py diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index 3ce84d60a91..dfed411745f 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -16,8 +16,6 @@ from homeassistant.const import ( from homeassistant.helpers.event import async_track_sunrise, async_track_sunset import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ['sun'] - _LOGGER = logging.getLogger(__name__) TRIGGER_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index 9119394e357..a1297c5c118 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -14,12 +14,13 @@ from homeassistant.core import callback import homeassistant.util.dt as dt_util from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.helpers.event import ( - async_track_point_in_time, async_track_state_change) + async_track_point_in_utc_time, async_track_state_change) +from homeassistant.helpers.sun import is_up, get_astral_event_next from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv DOMAIN = 'device_sun_light_trigger' -DEPENDENCIES = ['light', 'device_tracker', 'group', 'sun'] +DEPENDENCIES = ['light', 'device_tracker', 'group'] CONF_DEVICE_GROUP = 'device_group' CONF_DISABLE_TURN_OFF = 'disable_turn_off' @@ -50,7 +51,6 @@ def async_setup(hass, config): device_tracker = get_component('device_tracker') group = get_component('group') light = get_component('light') - sun = get_component('sun') conf = config[DOMAIN] disable_turn_off = conf.get(CONF_DISABLE_TURN_OFF) light_group = conf.get(CONF_LIGHT_GROUP, light.ENTITY_ID_ALL_LIGHTS) @@ -78,7 +78,7 @@ def async_setup(hass, config): Async friendly. """ - next_setting = sun.next_setting(hass) + next_setting = get_astral_event_next(hass, 'sunset') if not next_setting: return None return next_setting - LIGHT_TRANSITION_TIME * len(light_ids) @@ -103,7 +103,7 @@ def async_setup(hass, config): # Track every time sun rises so we can schedule a time-based # pre-sun set event @callback - def schedule_light_turn_on(entity, old_state, new_state): + def schedule_light_turn_on(now): """Turn on all the lights at the moment sun sets. We will schedule to have each light start after one another @@ -114,26 +114,26 @@ def async_setup(hass, config): return for index, light_id in enumerate(light_ids): - async_track_point_in_time( + async_track_point_in_utc_time( hass, async_turn_on_factory(light_id), start_point + index * LIGHT_TRANSITION_TIME) - async_track_state_change(hass, sun.ENTITY_ID, schedule_light_turn_on, - sun.STATE_BELOW_HORIZON, sun.STATE_ABOVE_HORIZON) + async_track_point_in_utc_time(hass, schedule_light_turn_on, + get_astral_event_next(hass, 'sunrise')) # If the sun is already above horizon schedule the time-based pre-sun set # event. - if sun.is_on(hass): - schedule_light_turn_on(None, None, None) + if is_up(hass): + schedule_light_turn_on(None) @callback def check_light_on_dev_state_change(entity, old_state, new_state): """Handle tracked device state changes.""" lights_are_on = group.is_on(hass, light_group) - light_needed = not (lights_are_on or sun.is_on(hass)) + light_needed = not (lights_are_on or is_up(hass)) # These variables are needed for the elif check - now = dt_util.now() + now = dt_util.utcnow() start_point = calc_time_for_light_when_sunset() # Do we need lights? @@ -146,7 +146,7 @@ def async_setup(hass, config): # Check this by seeing if current time is later then the point # in time when we would start putting the lights on. elif (start_point and - start_point < now < sun.next_setting(hass)): + start_point < now < get_astral_event_next(hass, 'sunset')): # Check for every light if it would be on if someone was home # when the fading in started and turn it on if so diff --git a/homeassistant/components/sensor/moon.py b/homeassistant/components/sensor/moon.py index dc890c0f3cd..ca79e5241c4 100644 --- a/homeassistant/components/sensor/moon.py +++ b/homeassistant/components/sensor/moon.py @@ -15,8 +15,6 @@ import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['astral==1.4'] - _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Moon' diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index b2af30d8438..8254b4b2f0e 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -4,20 +4,18 @@ Support for functionality to keep track of the sun. For more details about this component, please refer to the documentation at https://home-assistant.io/components/sun/ """ +import asyncio import logging from datetime import timedelta -import voluptuous as vol - from homeassistant.const import CONF_ELEVATION +from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( - track_point_in_utc_time, track_utc_time_change) + async_track_point_in_utc_time, async_track_utc_time_change) +from homeassistant.helpers.sun import ( + get_astral_location, get_astral_event_next) from homeassistant.util import dt as dt_util -import homeassistant.helpers.config_validation as cv -import homeassistant.util as util - -REQUIREMENTS = ['astral==1.4'] _LOGGER = logging.getLogger(__name__) @@ -37,223 +35,16 @@ STATE_ATTR_NEXT_NOON = 'next_noon' STATE_ATTR_NEXT_RISING = 'next_rising' STATE_ATTR_NEXT_SETTING = 'next_setting' -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_ELEVATION): cv.positive_int, - }), -}, extra=vol.ALLOW_EXTRA) - -def is_on(hass, entity_id=None): - """Test if the sun is currently up based on the statemachine.""" - entity_id = entity_id or ENTITY_ID - - return hass.states.is_state(entity_id, STATE_ABOVE_HORIZON) - - -def next_dawn(hass, entity_id=None): - """Local datetime object of the next dawn. - - Async friendly. - """ - utc_next = next_dawn_utc(hass, entity_id) - - return dt_util.as_local(utc_next) if utc_next else None - - -def next_dawn_utc(hass, entity_id=None): - """UTC datetime object of the next dawn. - - Async friendly. - """ - entity_id = entity_id or ENTITY_ID - - state = hass.states.get(ENTITY_ID) - - try: - return dt_util.parse_datetime( - state.attributes[STATE_ATTR_NEXT_DAWN]) - except (AttributeError, KeyError): - # AttributeError if state is None - # KeyError if STATE_ATTR_NEXT_DAWN does not exist - return None - - -def next_dusk(hass, entity_id=None): - """Local datetime object of the next dusk. - - Async friendly. - """ - utc_next = next_dusk_utc(hass, entity_id) - - return dt_util.as_local(utc_next) if utc_next else None - - -def next_dusk_utc(hass, entity_id=None): - """UTC datetime object of the next dusk. - - Async friendly. - """ - entity_id = entity_id or ENTITY_ID - - state = hass.states.get(ENTITY_ID) - - try: - return dt_util.parse_datetime( - state.attributes[STATE_ATTR_NEXT_DUSK]) - except (AttributeError, KeyError): - # AttributeError if state is None - # KeyError if STATE_ATTR_NEXT_DUSK does not exist - return None - - -def next_midnight(hass, entity_id=None): - """Local datetime object of the next midnight. - - Async friendly. - """ - utc_next = next_midnight_utc(hass, entity_id) - - return dt_util.as_local(utc_next) if utc_next else None - - -def next_midnight_utc(hass, entity_id=None): - """UTC datetime object of the next midnight. - - Async friendly. - """ - entity_id = entity_id or ENTITY_ID - - state = hass.states.get(ENTITY_ID) - - try: - return dt_util.parse_datetime( - state.attributes[STATE_ATTR_NEXT_MIDNIGHT]) - except (AttributeError, KeyError): - # AttributeError if state is None - # KeyError if STATE_ATTR_NEXT_MIDNIGHT does not exist - return None - - -def next_noon(hass, entity_id=None): - """Local datetime object of the next solar noon. - - Async friendly. - """ - utc_next = next_noon_utc(hass, entity_id) - - return dt_util.as_local(utc_next) if utc_next else None - - -def next_noon_utc(hass, entity_id=None): - """UTC datetime object of the next noon. - - Async friendly. - """ - entity_id = entity_id or ENTITY_ID - - state = hass.states.get(ENTITY_ID) - - try: - return dt_util.parse_datetime( - state.attributes[STATE_ATTR_NEXT_NOON]) - except (AttributeError, KeyError): - # AttributeError if state is None - # KeyError if STATE_ATTR_NEXT_NOON does not exist - return None - - -def next_setting(hass, entity_id=None): - """Local datetime object of the next sun setting. - - Async friendly. - """ - utc_next = next_setting_utc(hass, entity_id) - - return dt_util.as_local(utc_next) if utc_next else None - - -def next_setting_utc(hass, entity_id=None): - """UTC datetime object of the next sun setting. - - Async friendly. - """ - entity_id = entity_id or ENTITY_ID - - state = hass.states.get(ENTITY_ID) - - try: - return dt_util.parse_datetime( - state.attributes[STATE_ATTR_NEXT_SETTING]) - except (AttributeError, KeyError): - # AttributeError if state is None - # KeyError if STATE_ATTR_NEXT_SETTING does not exist - return None - - -def next_rising(hass, entity_id=None): - """Local datetime object of the next sun rising. - - Async friendly. - """ - utc_next = next_rising_utc(hass, entity_id) - - return dt_util.as_local(utc_next) if utc_next else None - - -def next_rising_utc(hass, entity_id=None): - """UTC datetime object of the next sun rising. - - Async friendly. - """ - entity_id = entity_id or ENTITY_ID - - state = hass.states.get(ENTITY_ID) - - try: - return dt_util.parse_datetime(state.attributes[STATE_ATTR_NEXT_RISING]) - except (AttributeError, KeyError): - # AttributeError if state is None - # KeyError if STATE_ATTR_NEXT_RISING does not exist - return None - - -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Track the state of the sun.""" - if None in (hass.config.latitude, hass.config.longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return False + if config.get(CONF_ELEVATION) is not None: + _LOGGER.warning( + "Elevation is now configured in home assistant core. " + "See https://home-assistant.io/docs/configuration/basic/") - latitude = util.convert(hass.config.latitude, float) - longitude = util.convert(hass.config.longitude, float) - errors = [] - - if latitude is None: - errors.append('Latitude needs to be a decimal value') - elif -90 > latitude < 90: - errors.append('Latitude needs to be -90 .. 90') - - if longitude is None: - errors.append('Longitude needs to be a decimal value') - elif -180 > longitude < 180: - errors.append('Longitude needs to be -180 .. 180') - - if errors: - _LOGGER.error('Invalid configuration received: %s', ", ".join(errors)) - return False - - platform_config = config.get(DOMAIN, {}) - - elevation = platform_config.get(CONF_ELEVATION) - if elevation is None: - elevation = hass.config.elevation or 0 - - from astral import Location - - location = Location(('', '', latitude, longitude, - hass.config.time_zone.zone, elevation)) - - sun = Sun(hass, location) + sun = Sun(hass, get_astral_location(hass)) sun.point_in_time_listener(dt_util.utcnow()) return True @@ -273,7 +64,7 @@ class Sun(Entity): self.next_midnight = self.next_noon = None self.solar_elevation = self.solar_azimuth = 0 - track_utc_time_change(hass, self.timer_update, second=30) + async_track_utc_time_change(hass, self.timer_update, second=30) @property def name(self): @@ -308,64 +99,41 @@ class Sun(Entity): return min(self.next_dawn, self.next_dusk, self.next_midnight, self.next_noon, self.next_rising, self.next_setting) - @staticmethod - def get_next_solar_event(callable_on_astral_location, - utc_point_in_time, mod, increment): - """Calculate sun state at a point in UTC time.""" - import astral - - while True: - try: - next_dt = callable_on_astral_location( - utc_point_in_time + timedelta(days=mod), local=False) - if next_dt > utc_point_in_time: - break - except astral.AstralError: - pass - mod += increment - - return next_dt - + @callback def update_as_of(self, utc_point_in_time): """Update the attributes containing solar events.""" - self.next_dawn = Sun.get_next_solar_event( - self.location.dawn, utc_point_in_time, -1, 1) - self.next_dusk = Sun.get_next_solar_event( - self.location.dusk, utc_point_in_time, -1, 1) - self.next_midnight = Sun.get_next_solar_event( - self.location.solar_midnight, utc_point_in_time, -1, 1) - self.next_noon = Sun.get_next_solar_event( - self.location.solar_noon, utc_point_in_time, -1, 1) - self.next_rising = Sun.get_next_solar_event( - self.location.sunrise, utc_point_in_time, -1, 1) - self.next_setting = Sun.get_next_solar_event( - self.location.sunset, utc_point_in_time, -1, 1) + self.next_dawn = get_astral_event_next( + self.hass, 'dawn', utc_point_in_time) + self.next_dusk = get_astral_event_next( + self.hass, 'dusk', utc_point_in_time) + self.next_midnight = get_astral_event_next( + self.hass, 'solar_midnight', utc_point_in_time) + self.next_noon = get_astral_event_next( + self.hass, 'solar_noon', utc_point_in_time) + self.next_rising = get_astral_event_next( + self.hass, 'sunrise', utc_point_in_time) + self.next_setting = get_astral_event_next( + self.hass, 'sunset', utc_point_in_time) + @callback def update_sun_position(self, utc_point_in_time): """Calculate the position of the sun.""" - from astral import Astral - - self.solar_azimuth = Astral().solar_azimuth( - utc_point_in_time, - self.location.latitude, - self.location.longitude) - - self.solar_elevation = Astral().solar_elevation( - utc_point_in_time, - self.location.latitude, - self.location.longitude) + self.solar_azimuth = self.location.solar_azimuth(utc_point_in_time) + self.solar_elevation = self.location.solar_elevation(utc_point_in_time) + @callback def point_in_time_listener(self, now): """Run when the state of the sun has changed.""" self.update_as_of(now) - self.schedule_update_ha_state() + self.hass.async_add_job(self.async_update_ha_state()) # Schedule next update at next_change+1 second so sun state has changed - track_point_in_utc_time( + async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.next_change + timedelta(seconds=1)) + @callback def timer_update(self, time): """Needed to update solar elevation and azimuth.""" self.update_sun_position(time) - self.schedule_update_ha_state() + self.hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 2052ffc4c15..daa4d1f8cd1 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -11,18 +11,18 @@ import logging import voluptuous as vol from homeassistant.components.light import is_on, turn_on -from homeassistant.components.sun import next_setting, next_rising from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.const import CONF_NAME, CONF_PLATFORM from homeassistant.helpers.event import track_time_change +from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util.color import ( color_temperature_to_rgb, color_RGB_to_xy, color_temperature_kelvin_to_mired) from homeassistant.util.dt import now as dt_now import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ['sun', 'light'] -SUN = "sun.sun" +DEPENDENCIES = ['light'] + _LOGGER = logging.getLogger(__name__) CONF_LIGHTS = 'lights' @@ -159,8 +159,7 @@ class FluxSwitch(SwitchDevice): """Update all the lights using flux.""" if now is None: now = dt_now() - sunset = next_setting(self.hass, SUN).replace( - day=now.day, month=now.month, year=now.year) + sunset = get_astral_event_date(self.hass, 'sunset', now.date()) start_time = self.find_start_time(now) stop_time = now.replace( hour=self._stop_time.hour, minute=self._stop_time.minute, @@ -221,6 +220,5 @@ class FluxSwitch(SwitchDevice): hour=self._start_time.hour, minute=self._start_time.minute, second=0) else: - sunrise = next_rising(self.hass, SUN).replace( - day=now.day, month=now.month, year=now.year) + sunrise = get_astral_event_date(self.hass, 'sunrise', now.date()) return sunrise diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index bbfb19f7806..a0753b0f766 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -7,8 +7,7 @@ import sys from homeassistant.helpers.typing import ConfigType from homeassistant.core import HomeAssistant -from homeassistant.components import ( - zone as zone_cmp, sun as sun_cmp) +from homeassistant.components import zone as zone_cmp from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, CONF_CONDITION, @@ -17,6 +16,7 @@ from homeassistant.const import ( CONF_BELOW, CONF_ABOVE) from homeassistant.exceptions import TemplateError, HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.sun import get_astral_event_date import homeassistant.util.dt as dt_util from homeassistant.util.async import run_callback_threadsafe @@ -234,24 +234,34 @@ def state_from_config(config, config_validation=True): def sun(hass, before=None, after=None, before_offset=None, after_offset=None): """Test if current time matches sun requirements.""" - now = dt_util.now().time() + utcnow = dt_util.utcnow() + today = dt_util.as_local(utcnow).date() before_offset = before_offset or timedelta(0) after_offset = after_offset or timedelta(0) - if before == SUN_EVENT_SUNRISE and now > (sun_cmp.next_rising(hass) + - before_offset).time(): + sunrise = get_astral_event_date(hass, 'sunrise', today) + sunset = get_astral_event_date(hass, 'sunset', today) + + if sunrise is None and (before == SUN_EVENT_SUNRISE or + after == SUN_EVENT_SUNRISE): + # There is no sunrise today return False - elif before == SUN_EVENT_SUNSET and now > (sun_cmp.next_setting(hass) + - before_offset).time(): + if sunset is None and (before == SUN_EVENT_SUNSET or + after == SUN_EVENT_SUNSET): + # There is no sunset today return False - if after == SUN_EVENT_SUNRISE and now < (sun_cmp.next_rising(hass) + - after_offset).time(): + if before == SUN_EVENT_SUNRISE and utcnow > sunrise + before_offset: return False - elif after == SUN_EVENT_SUNSET and now < (sun_cmp.next_setting(hass) + - after_offset).time(): + elif before == SUN_EVENT_SUNSET and utcnow > sunset + before_offset: + return False + + if after == SUN_EVENT_SUNRISE and utcnow < sunrise + after_offset: + return False + + elif after == SUN_EVENT_SUNSET and utcnow < sunset + after_offset: return False return True diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 0cdcca42eca..d3ad93d3646 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1,7 +1,7 @@ """Helpers for listening to events.""" import functools as ft -from datetime import timedelta +from homeassistant.helpers.sun import get_astral_event_next from ..core import HomeAssistant, callback from ..const import ( ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) @@ -197,29 +197,20 @@ track_time_interval = threaded_listener_factory(async_track_time_interval) @callback def async_track_sunrise(hass, action, offset=None): """Add a listener that will fire a specified offset from sunrise daily.""" - from homeassistant.components import sun - offset = offset or timedelta() remove = None - def next_rise(): - """Return the next sunrise.""" - next_time = sun.next_rising_utc(hass) + offset - - while next_time < dt_util.utcnow(): - next_time = next_time + timedelta(days=1) - - return next_time - @callback def sunrise_automation_listener(now): """Handle points in time to execute actions.""" nonlocal remove remove = async_track_point_in_utc_time( - hass, sunrise_automation_listener, next_rise()) + hass, sunrise_automation_listener, get_astral_event_next( + hass, 'sunrise', offset=offset)) hass.async_run_job(action) remove = async_track_point_in_utc_time( - hass, sunrise_automation_listener, next_rise()) + hass, sunrise_automation_listener, get_astral_event_next( + hass, 'sunrise', offset=offset)) def remove_listener(): """Remove sunset listener.""" @@ -234,29 +225,20 @@ track_sunrise = threaded_listener_factory(async_track_sunrise) @callback def async_track_sunset(hass, action, offset=None): """Add a listener that will fire a specified offset from sunset daily.""" - from homeassistant.components import sun - offset = offset or timedelta() remove = None - def next_set(): - """Return next sunrise.""" - next_time = sun.next_setting_utc(hass) + offset - - while next_time < dt_util.utcnow(): - next_time = next_time + timedelta(days=1) - - return next_time - @callback def sunset_automation_listener(now): """Handle points in time to execute actions.""" nonlocal remove remove = async_track_point_in_utc_time( - hass, sunset_automation_listener, next_set()) + hass, sunset_automation_listener, get_astral_event_next( + hass, 'sunset', offset=offset)) hass.async_run_job(action) remove = async_track_point_in_utc_time( - hass, sunset_automation_listener, next_set()) + hass, sunset_automation_listener, get_astral_event_next( + hass, 'sunset', offset=offset)) def remove_listener(): """Remove sunset listener.""" diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py new file mode 100644 index 00000000000..157225c9903 --- /dev/null +++ b/homeassistant/helpers/sun.py @@ -0,0 +1,87 @@ +"""Helpers for sun events.""" +import datetime + +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +DATA_LOCATION_CACHE = 'astral_location_cache' + + +@callback +def get_astral_location(hass): + """Get an astral location for the current hass configuration.""" + from astral import Location + + latitude = hass.config.latitude + longitude = hass.config.longitude + timezone = hass.config.time_zone.zone + elevation = hass.config.elevation + info = ('', '', latitude, longitude, timezone, elevation) + + # Cache astral locations so they aren't recreated with the same args + if DATA_LOCATION_CACHE not in hass.data: + hass.data[DATA_LOCATION_CACHE] = {} + + if info not in hass.data[DATA_LOCATION_CACHE]: + hass.data[DATA_LOCATION_CACHE][info] = Location(info) + + return hass.data[DATA_LOCATION_CACHE][info] + + +@callback +def get_astral_event_next(hass, event, utc_point_in_time=None, offset=None): + """Calculate the next specified solar event.""" + import astral + + location = get_astral_location(hass) + + if offset is None: + offset = datetime.timedelta() + + if utc_point_in_time is None: + utc_point_in_time = dt_util.utcnow() + + mod = -1 + while True: + try: + next_dt = getattr(location, event)( + dt_util.as_local(utc_point_in_time).date() + + datetime.timedelta(days=mod), + local=False) + offset + if next_dt > utc_point_in_time: + return next_dt + except astral.AstralError: + pass + mod += 1 + + +@callback +def get_astral_event_date(hass, event, date=None): + """Calculate the astral event time for the specified date.""" + import astral + + location = get_astral_location(hass) + + if date is None: + date = dt_util.now().date() + + if isinstance(date, datetime.datetime): + date = dt_util.as_local(date).date() + + try: + return getattr(location, event)(date, local=False) + except astral.AstralError: + # Event never occurs for specified date. + return None + + +@callback +def is_up(hass, utc_point_in_time=None): + """Calculate if the sun is currently up.""" + if utc_point_in_time is None: + utc_point_in_time = dt_util.utcnow() + + next_sunrise = get_astral_event_next(hass, 'sunrise', utc_point_in_time) + next_sunset = get_astral_event_next(hass, 'sunset', utc_point_in_time) + + return next_sunrise > next_sunset diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f6774a0d5dc..39314f963ae 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,3 +8,4 @@ typing>=3,<4 aiohttp==2.0.7 async_timeout==1.2.1 chardet==3.0.2 +astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index d1767167ad9..9aa450753cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -9,6 +9,7 @@ typing>=3,<4 aiohttp==2.0.7 async_timeout==1.2.1 chardet==3.0.2 +astral==1.4 # homeassistant.components.nuimo_controller --only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0 @@ -66,10 +67,6 @@ apcaccess==0.0.4 # homeassistant.components.notify.apns apns2==0.1.1 -# homeassistant.components.sun -# homeassistant.components.sensor.moon -astral==1.4 - # homeassistant.components.light.avion # avion==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72c264fd775..0253f41f734 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -37,10 +37,6 @@ aiohttp_cors==0.5.3 # homeassistant.components.notify.apns apns2==0.1.1 -# homeassistant.components.sun -# homeassistant.components.sensor.moon -astral==1.4 - # homeassistant.components.datadog datadog==0.15.0 diff --git a/setup.py b/setup.py index d0f4ccbd75b..2cdcad544fb 100755 --- a/setup.py +++ b/setup.py @@ -24,7 +24,8 @@ REQUIRES = [ 'typing>=3,<4', 'aiohttp==2.0.7', 'async_timeout==1.2.1', - 'chardet==3.0.2' + 'chardet==3.0.2', + 'astral==1.4', ] setup( diff --git a/tests/common.py b/tests/common.py index 1585cb33e23..9d8f2e33065 100644 --- a/tests/common.py +++ b/tests/common.py @@ -3,7 +3,6 @@ import asyncio import functools as ft import os import sys -from datetime import timedelta from unittest.mock import patch, MagicMock, Mock from io import StringIO import logging @@ -25,7 +24,7 @@ from homeassistant.const import ( STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, ATTR_DISCOVERED, SERVER_PORT, EVENT_HOMEASSISTANT_CLOSE) -from homeassistant.components import sun, mqtt, recorder +from homeassistant.components import mqtt, recorder from homeassistant.components.http.auth import auth_middleware from homeassistant.components.http.const import ( KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED, KEY_TRUSTED_NETWORKS) @@ -213,20 +212,6 @@ def fire_service_discovered(hass, service, info): }) -def ensure_sun_risen(hass): - """Trigger sun to rise if below horizon.""" - if sun.is_on(hass): - return - fire_time_changed(hass, sun.next_rising_utc(hass) + timedelta(seconds=10)) - - -def ensure_sun_set(hass): - """Trigger sun to set if above horizon.""" - if not sun.is_on(hass): - return - fire_time_changed(hass, sun.next_setting_utc(hass) + timedelta(seconds=10)) - - def load_fixture(filename): """Load a fixture.""" path = os.path.join(os.path.dirname(__file__), 'fixtures', filename) diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py index 2341d22d633..ac1d7bc5acf 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -22,7 +22,8 @@ class TestAutomationSun(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() mock_component(self.hass, 'group') - mock_component(self.hass, 'sun') + setup_component(self.hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) self.calls = [] @@ -39,10 +40,6 @@ class TestAutomationSun(unittest.TestCase): def test_sunset_trigger(self): """Test the sunset trigger.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_SETTING: '2015-09-16T02:00:00Z', - }) - now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 2, tzinfo=dt_util.UTC) @@ -78,10 +75,6 @@ class TestAutomationSun(unittest.TestCase): def test_sunrise_trigger(self): """Test the sunrise trigger.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z', - }) - now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 14, tzinfo=dt_util.UTC) @@ -105,10 +98,6 @@ class TestAutomationSun(unittest.TestCase): def test_sunset_trigger_with_offset(self): """Test the sunset trigger with offset.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_SETTING: '2015-09-16T02:00:00Z', - }) - now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 2, 30, tzinfo=dt_util.UTC) @@ -139,10 +128,6 @@ class TestAutomationSun(unittest.TestCase): def test_sunrise_trigger_with_offset(self): """Test the runrise trigger with offset.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z', - }) - now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 13, 30, tzinfo=dt_util.UTC) @@ -167,10 +152,6 @@ class TestAutomationSun(unittest.TestCase): def test_if_action_before(self): """Test if action was before.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z', - }) - setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -188,14 +169,14 @@ class TestAutomationSun(unittest.TestCase): }) now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() self.assertEqual(0, len(self.calls)) now = datetime(2015, 9, 16, 10, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() @@ -203,10 +184,6 @@ class TestAutomationSun(unittest.TestCase): def test_if_action_after(self): """Test if action was after.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z', - }) - setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -224,14 +201,14 @@ class TestAutomationSun(unittest.TestCase): }) now = datetime(2015, 9, 16, 13, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() self.assertEqual(0, len(self.calls)) now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() @@ -239,10 +216,6 @@ class TestAutomationSun(unittest.TestCase): def test_if_action_before_with_offset(self): """Test if action was before offset.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z', - }) - setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -260,15 +233,15 @@ class TestAutomationSun(unittest.TestCase): } }) - now = datetime(2015, 9, 16, 15, 1, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + now = datetime(2015, 9, 16, 14, 32, 44, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() self.assertEqual(0, len(self.calls)) - now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + now = datetime(2015, 9, 16, 14, 32, 43, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() @@ -276,10 +249,6 @@ class TestAutomationSun(unittest.TestCase): def test_if_action_after_with_offset(self): """Test if action was after offset.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z', - }) - setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -297,15 +266,15 @@ class TestAutomationSun(unittest.TestCase): } }) - now = datetime(2015, 9, 16, 14, 59, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + now = datetime(2015, 9, 16, 14, 32, 42, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() self.assertEqual(0, len(self.calls)) - now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + now = datetime(2015, 9, 16, 14, 32, 43, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() @@ -313,11 +282,6 @@ class TestAutomationSun(unittest.TestCase): def test_if_action_before_and_after_during(self): """Test if action was before and after during.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_RISING: '2015-09-16T10:00:00Z', - sun.STATE_ATTR_NEXT_SETTING: '2015-09-16T15:00:00Z', - }) - setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -335,62 +299,22 @@ class TestAutomationSun(unittest.TestCase): } }) - now = datetime(2015, 9, 16, 9, 59, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + now = datetime(2015, 9, 16, 13, 8, 51, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() self.assertEqual(0, len(self.calls)) - now = datetime(2015, 9, 16, 15, 1, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', + now = datetime(2015, 9, 17, 2, 25, 18, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() self.assertEqual(0, len(self.calls)) - now = datetime(2015, 9, 16, 12, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.now', - return_value=now): - self.hass.bus.fire('test_event') - self.hass.block_till_done() - self.assertEqual(1, len(self.calls)) - - def test_if_action_after_different_tz(self): - """Test if action was after in a different timezone.""" - import pytz - - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { - sun.STATE_ATTR_NEXT_SETTING: '2015-09-16T17:30:00Z', - }) - - setup_component(self.hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'event', - 'event_type': 'test_event', - }, - 'condition': { - 'condition': 'sun', - 'after': 'sunset', - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - # Before - now = datetime(2015, 9, 16, 17, tzinfo=pytz.timezone('US/Mountain')) - with patch('homeassistant.util.dt.now', - return_value=now): - self.hass.bus.fire('test_event') - self.hass.block_till_done() - self.assertEqual(0, len(self.calls)) - - # After - now = datetime(2015, 9, 16, 18, tzinfo=pytz.timezone('US/Mountain')) - with patch('homeassistant.util.dt.now', + now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=now): self.hass.bus.fire('test_event') self.hass.block_till_done() diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index b42177a5f06..2422f0ea334 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -1,5 +1,4 @@ """The tests for the Flux switch platform.""" -from datetime import timedelta import unittest from unittest.mock import patch @@ -86,28 +85,30 @@ class TestSwitchFlux(unittest.TestCase): self.assertIsNone(state.attributes.get('xy_color')) self.assertIsNone(state.attributes.get('brightness')) - test_time = dt_util.now().replace(hour=10, minute=30, - second=0) - sunset_time = test_time.replace(hour=17, minute=0, - second=0) - sunrise_time = test_time.replace(hour=5, minute=0, - second=0) + timedelta(days=1) + test_time = dt_util.now().replace(hour=10, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() self.assertEqual(0, len(turn_on_calls)) def test_flux_before_sunrise(self): @@ -126,30 +127,32 @@ class TestSwitchFlux(unittest.TestCase): self.assertIsNone(state.attributes.get('xy_color')) self.assertIsNone(state.attributes.get('brightness')) - test_time = dt_util.now().replace(hour=2, minute=30, - second=0) - sunset_time = test_time.replace(hour=17, minute=0, - second=0) - sunrise_time = test_time.replace(hour=5, minute=0, - second=0) + timedelta(days=1) + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) @@ -173,28 +176,30 @@ class TestSwitchFlux(unittest.TestCase): test_time = dt_util.now().replace(hour=8, minute=30, second=0) sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, - minute=0, - second=0) + timedelta(days=1) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 180) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.431, 0.38]) @@ -218,28 +223,30 @@ class TestSwitchFlux(unittest.TestCase): test_time = dt_util.now().replace(hour=17, minute=30, second=0) sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, - minute=0, - second=0) + timedelta(days=1) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 153) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.496, 0.397]) @@ -263,28 +270,30 @@ class TestSwitchFlux(unittest.TestCase): test_time = dt_util.now().replace(hour=23, minute=30, second=0) sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, - minute=0, - second=0) + timedelta(days=1) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) @@ -308,30 +317,32 @@ class TestSwitchFlux(unittest.TestCase): test_time = dt_util.now().replace(hour=17, minute=30, second=0) sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, - minute=0, - second=0) + timedelta(days=1) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'start_time': '6:00', - 'stop_time': '23:30' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'start_time': '6:00', + 'stop_time': '23:30' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 154) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.494, 0.397]) @@ -355,30 +366,32 @@ class TestSwitchFlux(unittest.TestCase): test_time = dt_util.now().replace(hour=17, minute=30, second=0) sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, - minute=0, - second=0) + timedelta(days=1) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'start_colortemp': '1000', - 'stop_colortemp': '6000' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'start_colortemp': '1000', + 'stop_colortemp': '6000' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 167) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.461, 0.389]) @@ -402,29 +415,31 @@ class TestSwitchFlux(unittest.TestCase): test_time = dt_util.now().replace(hour=17, minute=30, second=0) sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, - minute=0, - second=0) + timedelta(days=1) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'brightness': 255 - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'brightness': 255 + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 255) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.496, 0.397]) @@ -460,30 +475,34 @@ class TestSwitchFlux(unittest.TestCase): test_time = dt_util.now().replace(hour=12, minute=0, second=0) sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, - minute=0, - second=0) + timedelta(days=1) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + print('sunrise {}'.format(sunrise_time)) + return sunrise_time + else: + print('sunset {}'.format(sunset_time)) + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id, - dev2.entity_id, - dev3.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id, + dev2.entity_id, + dev3.entity_id] + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386]) @@ -511,28 +530,30 @@ class TestSwitchFlux(unittest.TestCase): test_time = dt_util.now().replace(hour=8, minute=30, second=0) sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, - minute=0, - second=0) + timedelta(days=1) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.components.sun.next_rising', - return_value=sunrise_time): - with patch('homeassistant.components.sun.next_setting', - return_value=sunset_time): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'mode': 'mired' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - switch.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'mired' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_COLOR_TEMP], 269) diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index 2d2f7313199..5cd85a16a7a 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -1,17 +1,19 @@ """The tests device sun light trigger component.""" # pylint: disable=protected-access +from datetime import datetime import os import unittest +from unittest.mock import patch from homeassistant.setup import setup_component import homeassistant.loader as loader from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.components import ( - device_tracker, light, sun, device_sun_light_trigger) + device_tracker, light, device_sun_light_trigger) +from homeassistant.util import dt as dt_util from tests.common import ( - get_test_config_dir, get_test_home_assistant, ensure_sun_risen, - ensure_sun_set) + get_test_config_dir, get_test_home_assistant, fire_time_changed) KNOWN_DEV_YAML_PATH = os.path.join(get_test_config_dir(), @@ -61,26 +63,26 @@ class TestDeviceSunLightTrigger(unittest.TestCase): light.DOMAIN: {CONF_PLATFORM: 'test'} })) - self.assertTrue(setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}})) - def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() def test_lights_on_when_sun_sets(self): """Test lights go on when there is someone home and the sun sets.""" - self.assertTrue(setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { - device_sun_light_trigger.DOMAIN: {}})) + test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + self.assertTrue(setup_component( + self.hass, device_sun_light_trigger.DOMAIN, { + device_sun_light_trigger.DOMAIN: {}})) - ensure_sun_risen(self.hass) light.turn_off(self.hass) self.hass.block_till_done() - ensure_sun_set(self.hass) - self.hass.block_till_done() + test_time = test_time.replace(hour=3) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() self.assertTrue(light.is_on(self.hass)) @@ -105,17 +107,17 @@ class TestDeviceSunLightTrigger(unittest.TestCase): def test_lights_turn_on_when_coming_home_after_sun_set(self): \ # pylint: disable=invalid-name """Test lights turn on when coming home after sun set.""" - light.turn_off(self.hass) - ensure_sun_set(self.hass) + test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + light.turn_off(self.hass) + self.hass.block_till_done() - self.hass.block_till_done() + self.assertTrue(setup_component( + self.hass, device_sun_light_trigger.DOMAIN, { + device_sun_light_trigger.DOMAIN: {}})) - self.assertTrue(setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { - device_sun_light_trigger.DOMAIN: {}})) + self.hass.states.set( + device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) - self.hass.states.set( - device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) - - self.hass.block_till_done() + self.hass.block_till_done() self.assertTrue(light.is_on(self.hass)) diff --git a/tests/components/test_sun.py b/tests/components/test_sun.py index 659e4b1a43d..d5a4ecfcb81 100644 --- a/tests/components/test_sun.py +++ b/tests/components/test_sun.py @@ -24,118 +24,111 @@ class TestSun(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() - def test_is_on(self): - """Test is_on method.""" - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON) - self.assertTrue(sun.is_on(self.hass)) - self.hass.states.set(sun.ENTITY_ID, sun.STATE_BELOW_HORIZON) - self.assertFalse(sun.is_on(self.hass)) - def test_setting_rising(self): """Test retrieving sun setting and rising.""" - setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=utc_now): + setup_component(self.hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + + self.hass.block_till_done() + state = self.hass.states.get(sun.ENTITY_ID) from astral import Astral astral = Astral() - utc_now = dt_util.utcnow() + utc_today = utc_now.date() latitude = self.hass.config.latitude longitude = self.hass.config.longitude mod = -1 while True: - next_dawn = (astral.dawn_utc(utc_now + - timedelta(days=mod), latitude, longitude)) + next_dawn = (astral.dawn_utc( + utc_today + timedelta(days=mod), latitude, longitude)) if next_dawn > utc_now: break mod += 1 mod = -1 while True: - next_dusk = (astral.dusk_utc(utc_now + - timedelta(days=mod), latitude, longitude)) + next_dusk = (astral.dusk_utc( + utc_today + timedelta(days=mod), latitude, longitude)) if next_dusk > utc_now: break mod += 1 mod = -1 while True: - next_midnight = (astral.solar_midnight_utc(utc_now + - timedelta(days=mod), longitude)) + next_midnight = (astral.solar_midnight_utc( + utc_today + timedelta(days=mod), longitude)) if next_midnight > utc_now: break mod += 1 mod = -1 while True: - next_noon = (astral.solar_noon_utc(utc_now + - timedelta(days=mod), longitude)) + next_noon = (astral.solar_noon_utc( + utc_today + timedelta(days=mod), longitude)) if next_noon > utc_now: break mod += 1 mod = -1 while True: - next_rising = (astral.sunrise_utc(utc_now + - timedelta(days=mod), latitude, longitude)) + next_rising = (astral.sunrise_utc( + utc_today + timedelta(days=mod), latitude, longitude)) if next_rising > utc_now: break mod += 1 mod = -1 while True: - next_setting = (astral.sunset_utc(utc_now + - timedelta(days=mod), latitude, longitude)) + next_setting = (astral.sunset_utc( + utc_today + timedelta(days=mod), latitude, longitude)) if next_setting > utc_now: break mod += 1 - self.assertEqual(next_dawn, sun.next_dawn_utc(self.hass)) - self.assertEqual(next_dusk, sun.next_dusk_utc(self.hass)) - self.assertEqual(next_midnight, sun.next_midnight_utc(self.hass)) - self.assertEqual(next_noon, sun.next_noon_utc(self.hass)) - self.assertEqual(next_rising, sun.next_rising_utc(self.hass)) - self.assertEqual(next_setting, sun.next_setting_utc(self.hass)) - - # Point it at a state without the proper attributes - self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON) - self.assertIsNone(sun.next_dawn(self.hass)) - self.assertIsNone(sun.next_dusk(self.hass)) - self.assertIsNone(sun.next_midnight(self.hass)) - self.assertIsNone(sun.next_noon(self.hass)) - self.assertIsNone(sun.next_rising(self.hass)) - self.assertIsNone(sun.next_setting(self.hass)) - - # Point it at a non-existing state - self.assertIsNone(sun.next_dawn(self.hass, 'non.existing')) - self.assertIsNone(sun.next_dusk(self.hass, 'non.existing')) - self.assertIsNone(sun.next_midnight(self.hass, 'non.existing')) - self.assertIsNone(sun.next_noon(self.hass, 'non.existing')) - self.assertIsNone(sun.next_rising(self.hass, 'non.existing')) - self.assertIsNone(sun.next_setting(self.hass, 'non.existing')) + self.assertEqual(next_dawn, dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_DAWN])) + self.assertEqual(next_dusk, dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_DUSK])) + self.assertEqual(next_midnight, dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_MIDNIGHT])) + self.assertEqual(next_noon, dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_NOON])) + self.assertEqual(next_rising, dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_RISING])) + self.assertEqual(next_setting, dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_SETTING])) def test_state_change(self): """Test if the state changes at next setting/rising.""" - setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=now): + setup_component(self.hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) - if sun.is_on(self.hass): - test_state = sun.STATE_BELOW_HORIZON - test_time = sun.next_setting(self.hass) - else: - test_state = sun.STATE_ABOVE_HORIZON - test_time = sun.next_rising(self.hass) + self.hass.block_till_done() + test_time = dt_util.parse_datetime( + self.hass.states.get(sun.ENTITY_ID) + .attributes[sun.STATE_ATTR_NEXT_RISING]) self.assertIsNotNone(test_time) + self.assertEqual(sun.STATE_BELOW_HORIZON, + self.hass.states.get(sun.ENTITY_ID).state) + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: test_time + timedelta(seconds=5)}) self.hass.block_till_done() - self.assertEqual(test_state, self.hass.states.get(sun.ENTITY_ID).state) + self.assertEqual(sun.STATE_ABOVE_HORIZON, + self.hass.states.get(sun.ENTITY_ID).state) def test_norway_in_june(self): """Test location in Norway where the sun doesn't set in summer.""" @@ -150,9 +143,11 @@ class TestSun(unittest.TestCase): sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) state = self.hass.states.get(sun.ENTITY_ID) - assert state is not None - assert sun.next_rising_utc(self.hass) == \ + + assert dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_RISING]) == \ datetime(2016, 7, 25, 23, 23, 39, tzinfo=dt_util.UTC) - assert sun.next_setting_utc(self.hass) == \ + assert dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_SETTING]) == \ datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index ac60aae3fab..37ff8ba297e 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -25,6 +25,7 @@ from homeassistant.components import sun import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant +from unittest.mock import patch class TestEventHelpers(unittest.TestCase): @@ -302,24 +303,27 @@ class TestEventHelpers(unittest.TestCase): # Get next sunrise/sunset astral = Astral() - utc_now = dt_util.utcnow() + utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC) + utc_today = utc_now.date() mod = -1 while True: - next_rising = (astral.sunrise_utc(utc_now + - timedelta(days=mod), latitude, longitude)) + next_rising = (astral.sunrise_utc( + utc_today + timedelta(days=mod), latitude, longitude)) if next_rising > utc_now: break mod += 1 # Track sunrise runs = [] - unsub = track_sunrise(self.hass, lambda: runs.append(1)) + with patch('homeassistant.util.dt.utcnow', return_value=utc_now): + unsub = track_sunrise(self.hass, lambda: runs.append(1)) offset_runs = [] offset = timedelta(minutes=30) - unsub2 = track_sunrise(self.hass, lambda: offset_runs.append(1), - offset) + with patch('homeassistant.util.dt.utcnow', return_value=utc_now): + unsub2 = track_sunrise(self.hass, lambda: offset_runs.append(1), + offset) # run tests self._send_time_changed(next_rising - offset) @@ -334,7 +338,7 @@ class TestEventHelpers(unittest.TestCase): self._send_time_changed(next_rising + offset) self.hass.block_till_done() - self.assertEqual(2, len(runs)) + self.assertEqual(1, len(runs)) self.assertEqual(1, len(offset_runs)) unsub() @@ -342,7 +346,7 @@ class TestEventHelpers(unittest.TestCase): self._send_time_changed(next_rising + offset) self.hass.block_till_done() - self.assertEqual(2, len(runs)) + self.assertEqual(1, len(runs)) self.assertEqual(1, len(offset_runs)) def test_track_sunset(self): @@ -358,23 +362,27 @@ class TestEventHelpers(unittest.TestCase): # Get next sunrise/sunset astral = Astral() - utc_now = dt_util.utcnow() + utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC) + utc_today = utc_now.date() mod = -1 while True: - next_setting = (astral.sunset_utc(utc_now + - timedelta(days=mod), latitude, longitude)) + next_setting = (astral.sunset_utc( + utc_today + timedelta(days=mod), latitude, longitude)) if next_setting > utc_now: break mod += 1 # Track sunset runs = [] - unsub = track_sunset(self.hass, lambda: runs.append(1)) + with patch('homeassistant.util.dt.utcnow', return_value=utc_now): + unsub = track_sunset(self.hass, lambda: runs.append(1)) offset_runs = [] offset = timedelta(minutes=30) - unsub2 = track_sunset(self.hass, lambda: offset_runs.append(1), offset) + with patch('homeassistant.util.dt.utcnow', return_value=utc_now): + unsub2 = track_sunset( + self.hass, lambda: offset_runs.append(1), offset) # Run tests self._send_time_changed(next_setting - offset) @@ -389,7 +397,7 @@ class TestEventHelpers(unittest.TestCase): self._send_time_changed(next_setting + offset) self.hass.block_till_done() - self.assertEqual(2, len(runs)) + self.assertEqual(1, len(runs)) self.assertEqual(1, len(offset_runs)) unsub() @@ -397,7 +405,7 @@ class TestEventHelpers(unittest.TestCase): self._send_time_changed(next_setting + offset) self.hass.block_till_done() - self.assertEqual(2, len(runs)) + self.assertEqual(1, len(runs)) self.assertEqual(1, len(offset_runs)) def _send_time_changed(self, now): diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py new file mode 100644 index 00000000000..2cfe28e5178 --- /dev/null +++ b/tests/helpers/test_sun.py @@ -0,0 +1,227 @@ +"""The tests for the Sun helpers.""" +# pylint: disable=protected-access +import unittest +from unittest.mock import patch +from datetime import timedelta, datetime + +import homeassistant.util.dt as dt_util +import homeassistant.helpers.sun as sun + +from tests.common import get_test_home_assistant + + +# pylint: disable=invalid-name +class TestSun(unittest.TestCase): + """Test the sun helpers.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_next_events(self): + """Test retrieving next sun events.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + from astral import Astral + + astral = Astral() + utc_today = utc_now.date() + + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + + mod = -1 + while True: + next_dawn = (astral.dawn_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_dawn > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_dusk = (astral.dusk_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_dusk > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_midnight = (astral.solar_midnight_utc( + utc_today + timedelta(days=mod), longitude)) + if next_midnight > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_noon = (astral.solar_noon_utc( + utc_today + timedelta(days=mod), longitude)) + if next_noon > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_rising = (astral.sunrise_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_rising > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_setting = (astral.sunset_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_setting > utc_now: + break + mod += 1 + + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=utc_now): + self.assertEqual(next_dawn, sun.get_astral_event_next( + self.hass, 'dawn')) + self.assertEqual(next_dusk, sun.get_astral_event_next( + self.hass, 'dusk')) + self.assertEqual(next_midnight, sun.get_astral_event_next( + self.hass, 'solar_midnight')) + self.assertEqual(next_noon, sun.get_astral_event_next( + self.hass, 'solar_noon')) + self.assertEqual(next_rising, sun.get_astral_event_next( + self.hass, 'sunrise')) + self.assertEqual(next_setting, sun.get_astral_event_next( + self.hass, 'sunset')) + + def test_date_events(self): + """Test retrieving next sun events.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + from astral import Astral + + astral = Astral() + utc_today = utc_now.date() + + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + + dawn = astral.dawn_utc(utc_today, latitude, longitude) + dusk = astral.dusk_utc(utc_today, latitude, longitude) + midnight = astral.solar_midnight_utc(utc_today, longitude) + noon = astral.solar_noon_utc(utc_today, longitude) + sunrise = astral.sunrise_utc(utc_today, latitude, longitude) + sunset = astral.sunset_utc(utc_today, latitude, longitude) + + self.assertEqual(dawn, sun.get_astral_event_date( + self.hass, 'dawn', utc_today)) + self.assertEqual(dusk, sun.get_astral_event_date( + self.hass, 'dusk', utc_today)) + self.assertEqual(midnight, sun.get_astral_event_date( + self.hass, 'solar_midnight', utc_today)) + self.assertEqual(noon, sun.get_astral_event_date( + self.hass, 'solar_noon', utc_today)) + self.assertEqual(sunrise, sun.get_astral_event_date( + self.hass, 'sunrise', utc_today)) + self.assertEqual(sunset, sun.get_astral_event_date( + self.hass, 'sunset', utc_today)) + + def test_date_events_default_date(self): + """Test retrieving next sun events.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + from astral import Astral + + astral = Astral() + utc_today = utc_now.date() + + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + + dawn = astral.dawn_utc(utc_today, latitude, longitude) + dusk = astral.dusk_utc(utc_today, latitude, longitude) + midnight = astral.solar_midnight_utc(utc_today, longitude) + noon = astral.solar_noon_utc(utc_today, longitude) + sunrise = astral.sunrise_utc(utc_today, latitude, longitude) + sunset = astral.sunset_utc(utc_today, latitude, longitude) + + with patch('homeassistant.util.dt.now', return_value=utc_now): + self.assertEqual(dawn, sun.get_astral_event_date( + self.hass, 'dawn', utc_today)) + self.assertEqual(dusk, sun.get_astral_event_date( + self.hass, 'dusk', utc_today)) + self.assertEqual(midnight, sun.get_astral_event_date( + self.hass, 'solar_midnight', utc_today)) + self.assertEqual(noon, sun.get_astral_event_date( + self.hass, 'solar_noon', utc_today)) + self.assertEqual(sunrise, sun.get_astral_event_date( + self.hass, 'sunrise', utc_today)) + self.assertEqual(sunset, sun.get_astral_event_date( + self.hass, 'sunset', utc_today)) + + def test_date_events_accepts_datetime(self): + """Test retrieving next sun events.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + from astral import Astral + + astral = Astral() + utc_today = utc_now.date() + + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + + dawn = astral.dawn_utc(utc_today, latitude, longitude) + dusk = astral.dusk_utc(utc_today, latitude, longitude) + midnight = astral.solar_midnight_utc(utc_today, longitude) + noon = astral.solar_noon_utc(utc_today, longitude) + sunrise = astral.sunrise_utc(utc_today, latitude, longitude) + sunset = astral.sunset_utc(utc_today, latitude, longitude) + + self.assertEqual(dawn, sun.get_astral_event_date( + self.hass, 'dawn', utc_now)) + self.assertEqual(dusk, sun.get_astral_event_date( + self.hass, 'dusk', utc_now)) + self.assertEqual(midnight, sun.get_astral_event_date( + self.hass, 'solar_midnight', utc_now)) + self.assertEqual(noon, sun.get_astral_event_date( + self.hass, 'solar_noon', utc_now)) + self.assertEqual(sunrise, sun.get_astral_event_date( + self.hass, 'sunrise', utc_now)) + self.assertEqual(sunset, sun.get_astral_event_date( + self.hass, 'sunset', utc_now)) + + def test_is_up(self): + """Test retrieving next sun events.""" + utc_now = datetime(2016, 11, 1, 12, 0, 0, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=utc_now): + self.assertFalse(sun.is_up(self.hass)) + + utc_now = datetime(2016, 11, 1, 18, 0, 0, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=utc_now): + self.assertTrue(sun.is_up(self.hass)) + + def test_norway_in_june(self): + """Test location in Norway where the sun doesn't set in summer.""" + self.hass.config.latitude = 69.6 + self.hass.config.longitude = 18.8 + + june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) + + print(sun.get_astral_event_date(self.hass, 'sunrise', + datetime(2017, 7, 25))) + print(sun.get_astral_event_date(self.hass, 'sunset', + datetime(2017, 7, 25))) + + print(sun.get_astral_event_date(self.hass, 'sunrise', + datetime(2017, 7, 26))) + print(sun.get_astral_event_date(self.hass, 'sunset', + datetime(2017, 7, 26))) + + assert sun.get_astral_event_next(self.hass, 'sunrise', june) == \ + datetime(2016, 7, 25, 23, 23, 39, tzinfo=dt_util.UTC) + assert sun.get_astral_event_next(self.hass, 'sunset', june) == \ + datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC) + assert sun.get_astral_event_date(self.hass, 'sunrise', june) is None + assert sun.get_astral_event_date(self.hass, 'sunset', june) is None From 5cb33824255e6cf34bc7055b367d22e150bd0e8d Mon Sep 17 00:00:00 2001 From: abmantis Date: Tue, 9 May 2017 16:34:17 +0100 Subject: [PATCH 052/135] new source only forces "play" if the current state is "playing" (#7506) --- homeassistant/components/media_player/spotify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index a73a4a922ca..229dcd88691 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -213,7 +213,8 @@ class SpotifyMediaPlayer(MediaPlayerDevice): def select_source(self, source): """Select playback device.""" - self._player.transfer_playback(self._devices[source]) + self._player.transfer_playback(self._devices[source], + self._state == STATE_PLAYING) def play_media(self, media_type, media_id, **kwargs): """Play media.""" From b34c58386c519df135942e9ae751a2e7a0b925b8 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Tue, 9 May 2017 16:35:30 +0100 Subject: [PATCH 053/135] Correct retrieval of spotify shuffle state (#7505) Returned on the current playback response itself, not the device --- homeassistant/components/media_player/spotify.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 229dcd88691..8ceb245eb03 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -176,6 +176,7 @@ class SpotifyMediaPlayer(MediaPlayerDevice): self._state = STATE_PAUSED if current.get('is_playing'): self._state = STATE_PLAYING + self._shuffle = current.get('shuffle_state') device = current.get('device') if device is None: self._state = STATE_IDLE @@ -184,8 +185,6 @@ class SpotifyMediaPlayer(MediaPlayerDevice): self._volume = device.get('volume_percent') / 100 if device.get('name'): self._current_device = device.get('name') - if device.get('shuffle_state'): - self._shuffle = device.get('shuffle_state') def set_volume_level(self, volume): """Set the volume level.""" From d86dfb6336921691c13ce1dce8f8325678af9676 Mon Sep 17 00:00:00 2001 From: Marc Egli Date: Tue, 9 May 2017 18:35:51 +0200 Subject: [PATCH 054/135] Fix sonos sleep timer (#7503) --- homeassistant/components/media_player/sonos.py | 4 ++-- tests/components/media_player/test_sonos.py | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index da75b89c19d..c27ae16b926 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -163,9 +163,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif service.service == SERVICE_RESTORE: device.restore(service.data[ATTR_WITH_GROUP]) elif service.service == SERVICE_SET_TIMER: - device.set_timer(service.data[ATTR_SLEEP_TIME]) + device.set_sleep_timer(service.data[ATTR_SLEEP_TIME]) elif service.service == SERVICE_CLEAR_TIMER: - device.clear_timer() + device.clear_sleep_timer() device.schedule_update_ha_state(True) diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 0a111ef3b36..ebf92cb4d1a 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -48,10 +48,6 @@ class SoCoMock(): self.is_visible = True self.avTransport = AvTransportMock() - def clear_sleep_timer(self): - """Clear the sleep timer.""" - return - def get_sonos_favorites(self): """Get favorites list from sonos.""" return {'favorites': []} From 5d820ec188712303512e49b97481500fe6323296 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 9 May 2017 18:44:00 -0700 Subject: [PATCH 055/135] Add support for automation config panel (#7509) * Add support for automation config * Build fromtend * Lint --- .../components/automation/__init__.py | 36 ++++++- homeassistant/components/config/__init__.py | 88 +++++++++++++++--- homeassistant/components/config/automation.py | 20 ++++ homeassistant/components/frontend/version.py | 7 +- .../frontend/www_static/compatibility.js | 2 +- .../frontend/www_static/compatibility.js.gz | Bin 362 -> 355 bytes .../components/frontend/www_static/core.js | 2 +- .../components/frontend/www_static/core.js.gz | Bin 2695 -> 2678 bytes .../www_static/home-assistant-polymer | 2 +- .../panels/ha-panel-automation.html | 2 + .../panels/ha-panel-automation.html.gz | Bin 0 -> 40511 bytes .../www_static/panels/ha-panel-hassio.html | 2 +- .../www_static/panels/ha-panel-hassio.html.gz | Bin 7383 -> 7381 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2513 -> 2509 bytes homeassistant/config.py | 7 ++ 16 files changed, 144 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/config/automation.py create mode 100644 homeassistant/components/frontend/www_static/panels/ha-panel-automation.html create mode 100644 homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 5f59f760d0b..7c11f15862f 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -16,7 +16,7 @@ from homeassistant.core import CoreState from homeassistant import config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, - SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START) + SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID) from homeassistant.components import logbook from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import extract_domain_configs, script, condition @@ -26,6 +26,7 @@ from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.loader import get_platform from homeassistant.util.dt import utcnow import homeassistant.helpers.config_validation as cv +from homeassistant.components.frontend import register_built_in_panel DOMAIN = 'automation' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -81,6 +82,7 @@ _TRIGGER_SCHEMA = vol.All( _CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) PLATFORM_SCHEMA = vol.Schema({ + CONF_ID: cv.string, CONF_ALIAS: cv.string, vol.Optional(CONF_INITIAL_STATE): cv.boolean, vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, @@ -139,6 +141,14 @@ def reload(hass): hass.services.call(DOMAIN, SERVICE_RELOAD) +def async_reload(hass): + """Reload the automation from config. + + Returns a coroutine object. + """ + return hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + @asyncio.coroutine def async_setup(hass, config): """Set up the automation.""" @@ -215,15 +225,20 @@ def async_setup(hass, config): DOMAIN, service, turn_onoff_service_handler, descriptions.get(service), schema=SERVICE_SCHEMA) + if 'frontend' in hass.config.components: + register_built_in_panel(hass, 'automation', 'Automations', + 'mdi:playlist-play') + return True class AutomationEntity(ToggleEntity): """Entity to show status of entity.""" - def __init__(self, name, async_attach_triggers, cond_func, async_action, - hidden, initial_state): + def __init__(self, automation_id, name, async_attach_triggers, cond_func, + async_action, hidden, initial_state): """Initialize an automation entity.""" + self._id = automation_id self._name = name self._async_attach_triggers = async_attach_triggers self._async_detach_triggers = None @@ -346,6 +361,16 @@ class AutomationEntity(ToggleEntity): self.async_trigger) yield from self.async_update_ha_state() + @property + def device_state_attributes(self): + """Return automation attributes.""" + if self._id is None: + return None + + return { + CONF_ID: self._id + } + @asyncio.coroutine def _async_process_config(hass, config, component): @@ -359,6 +384,7 @@ def _async_process_config(hass, config, component): conf = config[config_key] for list_no, config_block in enumerate(conf): + automation_id = config_block.get(CONF_ID) name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, list_no) @@ -383,8 +409,8 @@ def _async_process_config(hass, config, component): config_block.get(CONF_TRIGGER, []), name ) entity = AutomationEntity( - name, async_attach_triggers, cond_func, action, hidden, - initial_state) + automation_id, name, async_attach_triggers, cond_func, action, + hidden, initial_state) entities.append(entity) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 1255043b6b5..0bc44501e28 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -5,7 +5,7 @@ import os import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import EVENT_COMPONENT_LOADED +from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID from homeassistant.setup import ( async_prepare_setup_platform, ATTR_COMPONENT) from homeassistant.components.frontend import register_built_in_panel @@ -14,8 +14,8 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'group', 'hassbian') -ON_DEMAND = ('zwave', ) +SECTIONS = ('core', 'group', 'hassbian', 'automation') +ON_DEMAND = ('zwave') @asyncio.coroutine @@ -60,7 +60,7 @@ def async_setup(hass, config): return True -class EditKeyBasedConfigView(HomeAssistantView): +class BaseEditConfigView(HomeAssistantView): """Configure a Group endpoint.""" def __init__(self, component, config_type, path, key_schema, data_schema, @@ -73,13 +73,29 @@ class EditKeyBasedConfigView(HomeAssistantView): self.data_schema = data_schema self.post_write_hook = post_write_hook + def _empty_config(self): + """Empty config if file not found.""" + raise NotImplementedError + + def _get_value(self, data, config_key): + """Get value.""" + raise NotImplementedError + + def _write_value(self, data, config_key, new_value): + """Set value.""" + raise NotImplementedError + @asyncio.coroutine def get(self, request, config_key): """Fetch device specific config.""" hass = request.app['hass'] - current = yield from hass.loop.run_in_executor( - None, _read, hass.config.path(self.path)) - return self.json(current.get(config_key, {})) + current = yield from self.read_config(hass) + value = self._get_value(current, config_key) + + if value is None: + return self.json_message('Resource not found', 404) + + return self.json(value) @asyncio.coroutine def post(self, request, config_key): @@ -104,10 +120,10 @@ class EditKeyBasedConfigView(HomeAssistantView): hass = request.app['hass'] path = hass.config.path(self.path) - current = yield from hass.loop.run_in_executor(None, _read, path) - current.setdefault(config_key, {}).update(data) + current = yield from self.read_config(hass) + self._write_value(current, config_key, data) - yield from hass.loop.run_in_executor(None, _write, path, current) + yield from hass.async_add_job(_write, path, current) if self.post_write_hook is not None: hass.async_add_job(self.post_write_hook(hass)) @@ -116,13 +132,59 @@ class EditKeyBasedConfigView(HomeAssistantView): 'result': 'ok', }) + @asyncio.coroutine + def read_config(self, hass): + """Read the config.""" + current = yield from hass.async_add_job( + _read, hass.config.path(self.path)) + if not current: + current = self._empty_config() + return current + + +class EditKeyBasedConfigView(BaseEditConfigView): + """Configure a list of entries.""" + + def _empty_config(self): + """Return an empty config.""" + return {} + + def _get_value(self, data, config_key): + """Get value.""" + return data.get(config_key, {}) + + def _write_value(self, data, config_key, new_value): + """Set value.""" + data.setdefault(config_key, {}).update(new_value) + + +class EditIdBasedConfigView(BaseEditConfigView): + """Configure key based config entries.""" + + def _empty_config(self): + """Return an empty config.""" + return [] + + def _get_value(self, data, config_key): + """Get value.""" + return next( + (val for val in data if val.get(CONF_ID) == config_key), None) + + def _write_value(self, data, config_key, new_value): + """Set value.""" + value = self._get_value(data, config_key) + + if value is None: + value = {CONF_ID: config_key} + data.append(value) + + value.update(new_value) + def _read(path): """Read YAML helper.""" if not os.path.isfile(path): - with open(path, 'w'): - pass - return {} + return None return load_yaml(path) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py new file mode 100644 index 00000000000..64eccfaa2b8 --- /dev/null +++ b/homeassistant/components/config/automation.py @@ -0,0 +1,20 @@ +"""Provide configuration end points for Z-Wave.""" +import asyncio + +from homeassistant.components.config import EditIdBasedConfigView +from homeassistant.components.automation import ( + PLATFORM_SCHEMA, DOMAIN, async_reload) +import homeassistant.helpers.config_validation as cv + + +CONFIG_PATH = 'automations.yaml' + + +@asyncio.coroutine +def async_setup(hass): + """Set up the Automation config API.""" + hass.http.register_view(EditIdBasedConfigView( + DOMAIN, 'config', CONFIG_PATH, cv.string, + PLATFORM_SCHEMA, post_write_hook=async_reload + )) + return True diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 0d649344862..f92bb64ff69 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,18 +1,19 @@ """DO NOT MODIFY. Auto-generated by script/fingerprint_frontend.""" FINGERPRINTS = { - "compatibility.js": "83d9c77748dafa9db49ae77d7f3d8fb0", - "core.js": "5d08475f03adb5969bd31855d5ca0cfd", + "compatibility.js": "8e4c44b5f4288cc48ec1ba94a9bec812", + "core.js": "8cc30e2ad9ee3df44fe7a17507099d88", "frontend.html": "5999c8fac69c503b846672cae75a12b0", "mdi.html": "f407a5a57addbe93817ee1b244d33fbe", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", + "panels/ha-panel-automation.html": "cc6fe23a97c1974b9f4165a7692bb280", "panels/ha-panel-config.html": "59d9eb28758b497a4d9b2428f978b9b1", "panels/ha-panel-dev-event.html": "2db9c218065ef0f61d8d08db8093cad2", "panels/ha-panel-dev-info.html": "61610e015a411cfc84edd2c4d489e71d", "panels/ha-panel-dev-service.html": "415552027cb083badeff5f16080410ed", "panels/ha-panel-dev-state.html": "d70314913b8923d750932367b1099750", "panels/ha-panel-dev-template.html": "567fbf86735e1b891e40c2f4060fec9b", - "panels/ha-panel-hassio.html": "23d175b6744c20e2fdf475b6efdaa1d3", + "panels/ha-panel-hassio.html": "41fc94a5dc9247ed7efa112614491c71", "panels/ha-panel-history.html": "89062c48c76206cad1cec14ddbb1cbb1", "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab", "panels/ha-panel-logbook.html": "6dd6a16f52117318b202e60f98400163", diff --git a/homeassistant/components/frontend/www_static/compatibility.js b/homeassistant/components/frontend/www_static/compatibility.js index c152c50ddfa..927b37e68ce 100644 --- a/homeassistant/components/frontend/www_static/compatibility.js +++ b/homeassistant/components/frontend/www_static/compatibility.js @@ -1 +1 @@ -!(function(){"use strict";function e(e,r){var t=arguments;if(void 0===e||null===e)throw new TypeError("Cannot convert first argument to object");for(var n=Object(e),o=1;o`=v$QVZ|<>R72LaAX+9@aaN?d012Aa z;A(jd?j+X67#K(FyuU|^&lGL~;U+%&3%nBrlG&?83@oaES0EO(qn3rGruzY8!9j%b z3nE1ob(CK%2{QY?k_4sEYzo{ZdAss}4WA2KF4b_?2J`$o;IVi*TNo4a{O;@?sWjTn zqgUV1w{pe=rL;F|T^X0=`(0J|#IVL4{85*ww*lVY@Poo7F4Vt4TamkPBm`|$@#L3z zUakP6IkKgPJePE^M-$?srip+%LP68>xE#+u6KvbM2FGzuPf$CA1e|T_?4io^@DJH8 zHacFqc1<_uYkecfDZX0Y!4@Vp$>Gty1$_y=ubuZ>p42KOGD=kO@(WML7T^>D0092; Br^)~T literal 362 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 literal 2695 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 BEja)H diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 9e7dc4a921f..ca82a411aa1 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 9e7dc4a921f86e60cc1f14afe254e5310b63e854 +Subproject commit ca82a411aa1e875ef0fc26e34bdd2033f5b99276 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html new file mode 100644 index 00000000000..081586c2f7e --- /dev/null +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..e717b38ea4bcf39acbe093a4bc03e6130d72fecf GIT binary patch literal 40511 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 X9!q!(B5kR!=1=}V)musg)IkCOxoYTS literal 0 HcmV?d00001 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html index 80d1686acf0..ca491348298 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html @@ -14,4 +14,4 @@ computeInstallStatus(addon) { return (addon && addon.installed) || 'Not installed'; }, -}); \ No newline at end of file +}); \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz index 5ed1205a99958263848ffedc2f48e134b50c3172..2a18410d99060cdb43efeb316028b7fc579b161f 100644 GIT binary patch delta 461 zcmV;;0W$vAIn_A_ABzYGXYdiR2NfHCZaFc`L}C#wNM#5&CA&1{XBgQmXjzEb7`dB` zX-{mo0BoOdEXGlnx-_nRanw~TotL#JGEGr`-#-lb zw}jkw4WYyC6I~Td%d|G8c5vfE+PZh!4eLBG!WyjpZuV?@2yCVq4*Wx!*xoA5OVTww zy*@{xi;}!hhX%BY|@q5sA z*YLUYwUiCh=t$!Ot$yU^jmu#0aGU&4uX>7(kB@S>wJX|X`DmA-i2?GBPi+k3kxALr z@`S5=*KU8qvyRLiq;R%XGHC(YogFrO0P&Or)!7qVyx3xDZ?@nM?jWzm7e`CsNi{l-FCw|4~(z|tG}B)+a3a&X@&#;kS4abO7oI* z4NtGnk?5l2@3$LRuN&{b_eOj}ddJN-0%FJAHymP~moxG%*FV_R{SEGYLAByjzRmqc zNvH2fdVEK(7q=occCmI#ec2FykP}tC0NYMo(-@awZGvf){?f1d8M*j!!Cl(JT|@jH zwB0p)E`2R!!!$b5_&}>4`FZ0q7(Co2Kh&$9qT}PETyE`(c3D2!rD$S+eB)Ca19@ap zcC|d=D&Mu+-|+0@c_X)p&Trv&^&R)x{hLs4G-v@E$o+lWyA#^j*7f*4gk(e5GGZ)5 z@o~pX`Wiadva6!?1N0eDd+M4fw3X5;(#q-Dp}QFCG*-&6jKmR;C0(Uw{|n|>&=&H9 F003Rn>%Ra1 diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 445c8d0b9df..7315b62aa3d 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","c255cdae4bfd691ee9fa43e38e8dc462"],["/frontend/panels/dev-event-2db9c218065ef0f61d8d08db8093cad2.html","b5b751e49b1bba55f633ae0d7a92677d"],["/frontend/panels/dev-info-61610e015a411cfc84edd2c4d489e71d.html","6568377ee31cbd78fedc003b317f7faf"],["/frontend/panels/dev-service-415552027cb083badeff5f16080410ed.html","a4b1ec9bfa5bc3529af7783ae56cb55c"],["/frontend/panels/dev-state-d70314913b8923d750932367b1099750.html","c61b5b1461959aac106400e122993e9e"],["/frontend/panels/dev-template-567fbf86735e1b891e40c2f4060fec9b.html","d2853ecf45de1dbadf49fe99a7424ef3"],["/frontend/panels/map-31c592c239636f91e07c7ac232a5ebc4.html","182580419ce2c935ae6ec65502b6db96"],["/static/compatibility-83d9c77748dafa9db49ae77d7f3d8fb0.js","5f05c83be2b028d577962f9625904806"],["/static/core-5d08475f03adb5969bd31855d5ca0cfd.js","1cd99ba798bfcff9768c9d2bb2f58a7c"],["/static/frontend-5999c8fac69c503b846672cae75a12b0.html","d6ce8eb348fbea599933b2a72beb1337"],["/static/mdi-f407a5a57addbe93817ee1b244d33fbe.html","5459090f217c77747b08d06e0bf73388"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","32b5a9b7ada86304bec6b43d3f2194f0"]],cacheName="sw-precache-v3--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var n=new URL(e);return"/"===n.pathname.slice(-1)&&(n.pathname+=t),n.toString()},cleanResponse=function(e){return e.redirected?("body"in e?Promise.resolve(e.body):e.blob()).then(function(t){return new Response(t,{headers:e.headers,status:e.status,statusText:e.statusText})}):Promise.resolve(e)},createCacheKey=function(e,t,n,a){var c=new URL(e);return a&&c.pathname.match(a)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(n)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var n=new URL(t).pathname;return e.some(function(e){return n.match(e)})},stripIgnoredUrlParameters=function(e,t){var n=new URL(e);return n.search=n.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),n.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],n=e[1],a=new URL(t,self.location),c=createCacheKey(a,hashParamName,n,!1);return[a.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(n){if(!t.has(n)){var a=new Request(n,{credentials:"same-origin"});return fetch(a).then(function(t){if(!t.ok)throw new Error("Request for "+n+" returned a response with status "+t.status);return cleanResponse(t).then(function(t){return e.put(n,t)})})}}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(n){return Promise.all(n.map(function(n){if(!t.has(n.url))return e.delete(n)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,n=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(n);t||(n=addDirectoryIndex(n,"index.html"),t=urlsToCacheKeys.has(n));!t&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(n=new URL("/",self.location).toString(),t=urlsToCacheKeys.has(n)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(n)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url)&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var n,a;for(n=0;nVT`w>*sND62ofqbOarlR2 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 bGZ4K)XMEKS_;v3EtnU8>_REydV-^4ap| Date: Wed, 10 May 2017 03:56:41 +0200 Subject: [PATCH 056/135] Zwave panel api (#7456) * # This is a combination of 3 commits. # The first commit's message is: Add seperate zwave panel # The 2nd commit message will be skipped: # unused import # The 3rd commit message will be skipped: # Use get for config * Add seperate zwave panel * more info * Add usercodeview * Improve api * Improve api * Separate api into own file. * disable missing import * review changes * Tests 1 * Verify that we fetch data from groups * Tests groups * config 1 * usercode 1 * Api mods * Tweak API * docstrings * 100% api testing --- homeassistant/components/zwave/__init__.py | 12 +- homeassistant/components/zwave/api.py | 95 ++++++++ tests/components/zwave/test_api.py | 260 +++++++++++++++++++++ 3 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/zwave/api.py create mode 100644 tests/components/zwave/test_api.py diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 4033e195be0..c49983b3178 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send) from homeassistant.components.frontend import register_built_in_panel +from . import api from . import const from .const import DOMAIN from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity @@ -66,6 +67,8 @@ DEFAULT_CONF_REFRESH_VALUE = False DEFAULT_CONF_REFRESH_DELAY = 5 DATA_ZWAVE_DICT = 'zwave_devices' +OZW_LOG_FILENAME = 'OZW_Log.txt' +URL_API_OZW_LOG = '/api/zwave/ozwlog' ZWAVE_NETWORK = 'zwave_network' RENAME_NODE_SCHEMA = vol.Schema({ @@ -383,7 +386,7 @@ def setup(hass, config): def rename_node(service): """Rename a node.""" node_id = service.data.get(const.ATTR_NODE_ID) - node = hass.data[ZWAVE_NETWORK].nodes[node_id] + node = network.nodes[node_id] name = service.data.get(const.ATTR_NAME) node.name = name _LOGGER.info( @@ -501,7 +504,7 @@ def setup(hass, config): # to be ready. for i in range(const.NETWORK_READY_WAIT_SECS): _LOGGER.debug( - "network state: %d %s", hass.data[ZWAVE_NETWORK].state, + "network state: %d %s", network.state, network.state_str) if network.state >= network.STATE_AWAKED: _LOGGER.info("Z-Wave ready after %d seconds", i) @@ -607,6 +610,11 @@ def setup(hass, config): if 'frontend' in hass.config.components: register_built_in_panel(hass, 'zwave', 'Z-Wave', 'mdi:nfc') + hass.http.register_view(api.ZWaveNodeGroupView) + hass.http.register_view(api.ZWaveNodeConfigView) + hass.http.register_view(api.ZWaveUserCodeView) + hass.http.register_static_path( + URL_API_OZW_LOG, hass.config.path(OZW_LOG_FILENAME), False) return True diff --git a/homeassistant/components/zwave/api.py b/homeassistant/components/zwave/api.py new file mode 100644 index 00000000000..9e3066f91c5 --- /dev/null +++ b/homeassistant/components/zwave/api.py @@ -0,0 +1,95 @@ +"""API class to give info to the Z-Wave panel.""" + +import logging +import homeassistant.core as ha +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_NOT_FOUND +from . import const + +_LOGGER = logging.getLogger(__name__) + +ZWAVE_NETWORK = 'zwave_network' + + +class ZWaveNodeGroupView(HomeAssistantView): + """View to return the nodes group configuration.""" + + url = r"/api/zwave/groups/{node_id:\d+}" + name = "api:zwave:groups" + + @ha.callback + def get(self, request, node_id): + """Retrieve groups of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(ZWAVE_NETWORK) + node = network.nodes.get(nodeid) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + groupdata = node.groups + groups = {} + for key, value in groupdata.items(): + groups[key] = {'associations': value.associations, + 'association_instances': + value.associations_instances, + 'label': value.label, + 'max_associations': value.max_associations} + return self.json(groups) + + +class ZWaveNodeConfigView(HomeAssistantView): + """View to return the nodes configuration options.""" + + url = r"/api/zwave/config/{node_id:\d+}" + name = "api:zwave:config" + + @ha.callback + def get(self, request, node_id): + """Retrieve configurations of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(ZWAVE_NETWORK) + node = network.nodes.get(nodeid) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + config = {} + for value in ( + node.get_values(class_id=const.COMMAND_CLASS_CONFIGURATION) + .values()): + config[value.index] = {'label': value.label, + 'type': value.type, + 'help': value.help, + 'data_items': value.data_items, + 'data': value.data, + 'max': value.max, + 'min': value.min} + return self.json(config) + + +class ZWaveUserCodeView(HomeAssistantView): + """View to return the nodes usercode configuration.""" + + url = r"/api/zwave/usercodes/{node_id:\d+}" + name = "api:zwave:usercodes" + + @ha.callback + def get(self, request, node_id): + """Retrieve usercodes of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(ZWAVE_NETWORK) + node = network.nodes.get(nodeid) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + usercodes = {} + if not node.has_command_class(const.COMMAND_CLASS_USER_CODE): + return self.json(usercodes) + for value in ( + node.get_values(class_id=const.COMMAND_CLASS_USER_CODE) + .values()): + if value.genre != const.GENRE_USER: + continue + usercodes[value.index] = {'code': value.data, + 'label': value.label, + 'length': len(value.data)} + return self.json(usercodes) diff --git a/tests/components/zwave/test_api.py b/tests/components/zwave/test_api.py new file mode 100644 index 00000000000..aabfd39024c --- /dev/null +++ b/tests/components/zwave/test_api.py @@ -0,0 +1,260 @@ +"""Test Z-Wave config panel.""" +import asyncio +from unittest.mock import MagicMock +from homeassistant.components.zwave import ZWAVE_NETWORK, const +from homeassistant.components.zwave.api import ( + ZWaveNodeGroupView, ZWaveNodeConfigView, ZWaveUserCodeView) +from tests.common import mock_http_component_app +from tests.mock.zwave import MockNode, MockValue + + +@asyncio.coroutine +def test_get_groups(hass, test_client): + """Test getting groupdata on node.""" + app = mock_http_component_app(hass) + ZWaveNodeGroupView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + node = MockNode(node_id=2) + node.groups.associations = 'assoc' + node.groups.associations_instances = 'inst' + node.groups.label = 'the label' + node.groups.max_associations = 'max' + node.groups = {1: node.groups} + network.nodes = {2: node} + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/groups/2') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == { + '1': { + 'association_instances': 'inst', + 'associations': 'assoc', + 'label': 'the label', + 'max_associations': 'max' + } + } + + +@asyncio.coroutine +def test_get_groups_nogroups(hass, test_client): + """Test getting groupdata on node with no groups.""" + app = mock_http_component_app(hass) + ZWaveNodeGroupView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + node = MockNode(node_id=2) + + network.nodes = {2: node} + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/groups/2') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {} + + +@asyncio.coroutine +def test_get_groups_nonode(hass, test_client): + """Test getting groupdata on nonexisting node.""" + app = mock_http_component_app(hass) + ZWaveNodeGroupView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + network.nodes = {1: 1, 5: 5} + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/groups/2') + + assert resp.status == 404 + result = yield from resp.json() + + assert result == {'message': 'Node not found'} + + +@asyncio.coroutine +def test_get_config(hass, test_client): + """Test getting config on node.""" + app = mock_http_component_app(hass) + ZWaveNodeConfigView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + node = MockNode(node_id=2) + value = MockValue( + index=12, + command_class=const.COMMAND_CLASS_CONFIGURATION) + value.label = 'label' + value.help = 'help' + value.type = 'type' + value.data = 'data' + value.data_items = ['item1', 'item2'] + value.max = 'max' + value.min = 'min' + node.values = {12: value} + network.nodes = {2: node} + node.get_values.return_value = node.values + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/config/2') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {'12': {'data': 'data', + 'data_items': ['item1', 'item2'], + 'help': 'help', + 'label': 'label', + 'max': 'max', + 'min': 'min', + 'type': 'type'}} + + +@asyncio.coroutine +def test_get_config_noconfig_node(hass, test_client): + """Test getting config on node without config.""" + app = mock_http_component_app(hass) + ZWaveNodeConfigView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + node = MockNode(node_id=2) + + network.nodes = {2: node} + node.get_values.return_value = node.values + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/config/2') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {} + + +@asyncio.coroutine +def test_get_config_nonode(hass, test_client): + """Test getting config on nonexisting node.""" + app = mock_http_component_app(hass) + ZWaveNodeConfigView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + network.nodes = {1: 1, 5: 5} + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/config/2') + + assert resp.status == 404 + result = yield from resp.json() + + assert result == {'message': 'Node not found'} + + +@asyncio.coroutine +def test_get_usercodes_nonode(hass, test_client): + """Test getting usercodes on nonexisting node.""" + app = mock_http_component_app(hass) + ZWaveUserCodeView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + network.nodes = {1: 1, 5: 5} + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/usercodes/2') + + assert resp.status == 404 + result = yield from resp.json() + + assert result == {'message': 'Node not found'} + + +@asyncio.coroutine +def test_get_usercodes(hass, test_client): + """Test getting usercodes on node.""" + app = mock_http_component_app(hass) + ZWaveUserCodeView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + node = MockNode(node_id=18, + command_classes=[const.COMMAND_CLASS_USER_CODE]) + value = MockValue( + index=0, + command_class=const.COMMAND_CLASS_USER_CODE) + value.genre = const.GENRE_USER + value.label = 'label' + value.data = '1234' + node.values = {0: value} + network.nodes = {18: node} + node.get_values.return_value = node.values + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/usercodes/18') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {'0': {'code': '1234', + 'label': 'label', + 'length': 4}} + + +@asyncio.coroutine +def test_get_usercode_nousercode_node(hass, test_client): + """Test getting usercodes on node without usercodes.""" + app = mock_http_component_app(hass) + ZWaveUserCodeView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + node = MockNode(node_id=18) + + network.nodes = {18: node} + node.get_values.return_value = node.values + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/usercodes/18') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {} + + +@asyncio.coroutine +def test_get_usercodes_no_genreuser(hass, test_client): + """Test getting usercodes on node missing genre user.""" + app = mock_http_component_app(hass) + ZWaveUserCodeView().register(app.router) + + network = hass.data[ZWAVE_NETWORK] = MagicMock() + node = MockNode(node_id=18, + command_classes=[const.COMMAND_CLASS_USER_CODE]) + value = MockValue( + index=0, + command_class=const.COMMAND_CLASS_USER_CODE) + value.genre = const.GENRE_SYSTEM + value.label = 'label' + value.data = '1234' + node.values = {0: value} + network.nodes = {18: node} + node.get_values.return_value = node.values + + client = yield from test_client(app) + + resp = yield from client.get('/api/zwave/usercodes/18') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {} From 43296069c3c43e0faee51b255ca1bc470fdbc7bd Mon Sep 17 00:00:00 2001 From: Marc Egli Date: Wed, 10 May 2017 05:16:46 +0200 Subject: [PATCH 057/135] Update docker dev environment to python3.6 (#7520) * Update docker dev environment to python3.6 * comment out disable switches again --- script/test_docker | 2 +- virtualization/Docker/Dockerfile.dev | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/script/test_docker b/script/test_docker index 75b7cddf970..6d17492f703 100755 --- a/script/test_docker +++ b/script/test_docker @@ -10,4 +10,4 @@ docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.dev . docker run --rm \ -v `pwd`/.tox/:/usr/src/app/.tox/ \ -t -i home-assistant-test \ - tox -e py35 + tox -e py36 diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 0d546d12eb0..2f40ea5f409 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -2,7 +2,7 @@ # Based on the production Dockerfile, but with development additions. # Keep this file as close as possible to the production Dockerfile, so the environments match. -FROM python:3.5 +FROM python:3.6 MAINTAINER Paulus Schoutsen # Uncomment any of the following lines to disable the installation. @@ -37,11 +37,11 @@ RUN curl -sL https://deb.nodesource.com/setup_7.x | bash - && \ RUN pip3 install --no-cache-dir tox # Copy over everything required to run tox -COPY requirements_test.txt setup.cfg setup.py tox.ini ./ +COPY requirements_test_all.txt setup.cfg setup.py tox.ini ./ COPY homeassistant/const.py homeassistant/const.py # Prefetch dependencies for tox -RUN tox -e py35 --notest +RUN tox -e py36 --notest # END: Development additions From f4915ddb0b2fd3e7e037d8ea16fb6a02c2a2116b Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 10 May 2017 06:23:19 +0300 Subject: [PATCH 058/135] Switch basicmodem and python-roku to pypi (#7514) --- homeassistant/components/media_player/roku.py | 4 +--- homeassistant/components/sensor/modem_callerid.py | 4 +--- requirements_all.txt | 12 ++++++------ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 97f7ba2e716..a81f9330ab8 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -17,9 +17,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv import homeassistant.loader as loader -REQUIREMENTS = [ - 'https://github.com/bah2830/python-roku/archive/3.1.3.zip' - '#roku==3.1.3'] +REQUIREMENTS = ['python-roku==3.1.3'] KNOWN_HOSTS = [] DEFAULT_PORT = 8060 diff --git a/homeassistant/components/sensor/modem_callerid.py b/homeassistant/components/sensor/modem_callerid.py index e12ddb445ec..0b71540f346 100644 --- a/homeassistant/components/sensor/modem_callerid.py +++ b/homeassistant/components/sensor/modem_callerid.py @@ -14,9 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['https://github.com/vroomfonde1/basicmodem' - '/archive/0.7.zip' - '#basicmodem==0.7'] +REQUIREMENTS = ['basicmodem==0.7'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Modem CallerID' diff --git a/requirements_all.txt b/requirements_all.txt index 9aa450753cc..624b1f22b06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,6 +70,9 @@ apns2==0.1.1 # homeassistant.components.light.avion # avion==0.6 +# homeassistant.components.sensor.modem_callerid +basicmodem==0.7 + # homeassistant.components.sensor.linux_battery batinfo==0.4.2 @@ -264,9 +267,6 @@ https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 # homeassistant.components.cover.myq https://github.com/arraylabs/pymyq/archive/v0.0.8.zip#pymyq==0.0.8 -# homeassistant.components.media_player.roku -https://github.com/bah2830/python-roku/archive/3.1.3.zip#roku==3.1.3 - # homeassistant.components.modbus https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 @@ -306,9 +306,6 @@ https://github.com/tfriedel/python-lightify/archive/1bb1db0e7bd5b14304d7bb267e23 # homeassistant.components.lutron https://github.com/thecynic/pylutron/archive/v0.1.0.zip#pylutron==0.1.0 -# homeassistant.components.sensor.modem_callerid -https://github.com/vroomfonde1/basicmodem/archive/0.7.zip#basicmodem==0.7 - # homeassistant.components.tado https://github.com/wmalgadey/PyTado/archive/0.1.10.zip#PyTado==0.1.10 @@ -671,6 +668,9 @@ python-nmap==0.6.1 # homeassistant.components.notify.pushover python-pushover==0.2 +# homeassistant.components.media_player.roku +python-roku==3.1.3 + # homeassistant.components.sensor.synologydsm python-synology==0.1.0 From 1312ee0f7dce07ebfe804e585ffc67e438636ffb Mon Sep 17 00:00:00 2001 From: Gergely Imreh Date: Wed, 10 May 2017 05:29:38 +0100 Subject: [PATCH 059/135] sensor.envirophat: do not set up platform if hardware is not attached (#7438) * sensor.envirophat: do not set up platform if hardware is not attached Fixes comment from: https://github.com/home-assistant/home-assistant/pull/7427#discussion_r114703904 * Fix update logic. --- homeassistant/components/sensor/envirophat.py | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index 48370d76c83..fa694d837f0 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -54,7 +54,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sense HAT sensor platform.""" - data = EnvirophatData(config.get(CONF_USE_LEDS)) + try: + import envirophat + except OSError: + _LOGGER.error("No Enviro pHAT was found.") + return False + + data = EnvirophatData(envirophat, config.get(CONF_USE_LEDS)) dev = [] for variable in config[CONF_DISPLAY_OPTIONS]: @@ -97,9 +103,6 @@ class EnvirophatSensor(Entity): def update(self): """Get the latest data and updates the states.""" self.data.update() - if not self.data.light: - _LOGGER.error("Didn't receive data") - return if self.type == 'light': self._state = self.data.light @@ -138,8 +141,9 @@ class EnvirophatSensor(Entity): class EnvirophatData(object): """Get the latest data and update.""" - def __init__(self, use_leds): + def __init__(self, envirophat, use_leds): """Initialize the data object.""" + self.envirophat = envirophat self.use_leds = use_leds # sensors readings self.light = None @@ -162,34 +166,32 @@ class EnvirophatData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from Enviro pHAT.""" - from envirophat import analog, leds, light, motion, weather - # Light sensor reading: 16-bit integer - self.light = light.light() + self.light = self.envirophat.light.light() if self.use_leds: - # pylint: disable=no-value-for-parameter - leds.on() + self.envirophat.leds.on() # the three color values scaled agains the overall light, 0-255 - self.light_red, self.light_green, self.light_blue = light.rgb() + self.light_red, self.light_green, self.light_blue = \ + self.envirophat.light.rgb() if self.use_leds: # pylint: disable=no-value-for-parameter - leds.off() + self.envirophat.leds.off() # accelerometer readings in G self.accelerometer_x, self.accelerometer_y, self.accelerometer_z = \ - motion.accelerometer() + self.envirophat.motion.accelerometer() # raw magnetometer reading self.magnetometer_x, self.magnetometer_y, self.magnetometer_z = \ - motion.magnetometer() + self.envirophat.motion.magnetometer() # temperature resolution of BMP280 sensor: 0.01°C - self.temperature = round(weather.temperature(), 2) + self.temperature = round(self.envirophat.weather.temperature(), 2) # pressure resolution of BMP280 sensor: 0.16 Pa, rounding to 0.1 Pa # with conversion to 100 Pa = 1 hPa - self.pressure = round(weather.pressure() / 100.0, 3) + self.pressure = round(self.envirophat.weather.pressure() / 100.0, 3) # Voltage sensor, reading between 0-3.3V self.voltage_0, self.voltage_1, self.voltage_2, self.voltage_3 = \ - analog.read_all() + self.envirophat.analog.read_all() From b30c352e37506d82af78379e1b6d333358c7ccbb Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Wed, 10 May 2017 06:42:17 +0200 Subject: [PATCH 060/135] Telegram Bot enhancements with callback queries and new notification services (#7454) * telegram_bot and notify.telegram enhancements: - Receive callback queries and produce `telegram_callback` events. - Custom reply_markup (keyboard or inline_keyboard) for every type of message (message, photo, location & document). - `disable_notification`, `disable_web_page_preview`, `reply_to_message_id` and `parse_mode` optional keyword args. - Line break between title and message fields: `'{}\n{}'.format(title, message)` - Move Telegram notification services to `telegram_bot` component and forward service calls from the telegram notify service to the telegram component, so now the `notify.telegram` platform depends of `telegram_bot`, and there is no need for `api_key` in the notifier configuration. The notifier calls the new notification services of the bot component: - telegram_bot/send_message - telegram_bot/send_photo - telegram_bot/send_document - telegram_bot/send_location - telegram_bot/edit_message - telegram_bot/edit_caption - telegram_bot/edit_replymarkup - telegram_bot/answer_callback_query - Added descriptions of the new notification services with a services.yaml file. - CONFIG_SCHEMA instead of PLATFORM_SCHEMA for the `telegram_bot` component, so only one platform is allowed. - Async component setup. * telegram_bot and notify.telegram enhancements: change in requirements_all.txt. --- homeassistant/components/notify/telegram.py | 182 ++---- .../components/telegram_bot/__init__.py | 526 ++++++++++++++++-- .../components/telegram_bot/polling.py | 12 +- .../components/telegram_bot/services.yaml | 227 ++++++++ .../components/telegram_bot/webhooks.py | 54 +- requirements_all.txt | 4 +- 6 files changed, 763 insertions(+), 242 deletions(-) create mode 100644 homeassistant/components/telegram_bot/services.yaml diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 7ca2e1ed262..1bc2baa632e 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -4,186 +4,86 @@ Telegram platform for notify component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.telegram/ """ -import io import logging -import urllib -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import ( - CONF_API_KEY, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE) + ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA, ATTR_TARGET, + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import ATTR_LOCATION _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-telegram-bot==5.3.1'] +DOMAIN = 'telegram_bot' +DEPENDENCIES = [DOMAIN] -ATTR_PHOTO = 'photo' ATTR_KEYBOARD = 'keyboard' +ATTR_INLINE_KEYBOARD = 'inline_keyboard' +ATTR_PHOTO = 'photo' ATTR_DOCUMENT = 'document' -ATTR_CAPTION = 'caption' -ATTR_URL = 'url' -ATTR_FILE = 'file' -ATTR_USERNAME = 'username' -ATTR_PASSWORD = 'password' CONF_CHAT_ID = 'chat_id' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_CHAT_ID): cv.string, + vol.Required(CONF_CHAT_ID): cv.positive_int, }) def get_service(hass, config, discovery_info=None): """Get the Telegram notification service.""" - import telegram - - try: - chat_id = config.get(CONF_CHAT_ID) - api_key = config.get(CONF_API_KEY) - bot = telegram.Bot(token=api_key) - username = bot.getMe()['username'] - _LOGGER.debug("Telegram bot is '%s'", username) - except urllib.error.HTTPError: - _LOGGER.error("Please check your access token") - return None - - return TelegramNotificationService(api_key, chat_id) - - -def load_data(url=None, file=None, username=None, password=None): - """Load photo/document into ByteIO/File container from a source.""" - try: - if url is not None: - # Load photo from URL - if username is not None and password is not None: - req = requests.get(url, auth=(username, password), timeout=15) - else: - req = requests.get(url, timeout=15) - return io.BytesIO(req.content) - - elif file is not None: - # Load photo from file - return open(file, "rb") - else: - _LOGGER.warning("Can't load photo no photo found in params!") - - except OSError as error: - _LOGGER.error("Can't load photo into ByteIO: %s", error) - - return None + chat_id = config.get(CONF_CHAT_ID) + return TelegramNotificationService(hass, chat_id) class TelegramNotificationService(BaseNotificationService): """Implement the notification service for Telegram.""" - def __init__(self, api_key, chat_id): + def __init__(self, hass, chat_id): """Initialize the service.""" - import telegram - - self._api_key = api_key self._chat_id = chat_id - self.bot = telegram.Bot(token=self._api_key) + self.hass = hass def send_message(self, message="", **kwargs): """Send a message to a user.""" - import telegram - - title = kwargs.get(ATTR_TITLE) + service_data = dict(target=kwargs.get(ATTR_TARGET, self._chat_id)) + if ATTR_TITLE in kwargs: + service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)}) + if message: + service_data.update({ATTR_MESSAGE: message}) data = kwargs.get(ATTR_DATA) - # Exists data for send a photo/location + # Get keyboard info + if data is not None and ATTR_KEYBOARD in data: + keys = data.get(ATTR_KEYBOARD) + keys = keys if isinstance(keys, list) else [keys] + service_data.update(keyboard=keys) + elif data is not None and ATTR_INLINE_KEYBOARD in data: + keys = data.get(ATTR_INLINE_KEYBOARD) + keys = keys if isinstance(keys, list) else [keys] + service_data.update(inline_keyboard=keys) + + # Send a photo, a document or a location if data is not None and ATTR_PHOTO in data: photos = data.get(ATTR_PHOTO, None) photos = photos if isinstance(photos, list) else [photos] - for photo_data in photos: - self.send_photo(photo_data) + service_data.update(photo_data) + self.hass.services.call( + DOMAIN, 'send_photo', service_data=service_data) return elif data is not None and ATTR_LOCATION in data: - return self.send_location(data.get(ATTR_LOCATION)) + service_data.update(data.get(ATTR_LOCATION)) + return self.hass.services.call( + DOMAIN, 'send_location', service_data=service_data) elif data is not None and ATTR_DOCUMENT in data: - return self.send_document(data.get(ATTR_DOCUMENT)) - elif data is not None and ATTR_KEYBOARD in data: - keys = data.get(ATTR_KEYBOARD) - keys = keys if isinstance(keys, list) else [keys] - return self.send_keyboard(message, keys) - - if title: - text = '{} {}'.format(title, message) - else: - text = message - - parse_mode = telegram.parsemode.ParseMode.MARKDOWN + service_data.update(data.get(ATTR_DOCUMENT)) + return self.hass.services.call( + DOMAIN, 'send_document', service_data=service_data) # Send message - try: - self.bot.sendMessage( - chat_id=self._chat_id, text=text, parse_mode=parse_mode) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending message") - - def send_keyboard(self, message, keys): - """Display keyboard.""" - import telegram - - keyboard = telegram.ReplyKeyboardMarkup([ - [key.strip() for key in row.split(",")] for row in keys]) - try: - self.bot.sendMessage( - chat_id=self._chat_id, text=message, reply_markup=keyboard) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending message") - - def send_photo(self, data): - """Send a photo.""" - import telegram - caption = data.get(ATTR_CAPTION) - - # Send photo - try: - photo = load_data( - url=data.get(ATTR_URL), - file=data.get(ATTR_FILE), - username=data.get(ATTR_USERNAME), - password=data.get(ATTR_PASSWORD), - ) - self.bot.sendPhoto( - chat_id=self._chat_id, photo=photo, caption=caption) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending photo") - - def send_document(self, data): - """Send a document.""" - import telegram - caption = data.get(ATTR_CAPTION) - - # send photo - try: - document = load_data( - url=data.get(ATTR_URL), - file=data.get(ATTR_FILE), - username=data.get(ATTR_USERNAME), - password=data.get(ATTR_PASSWORD), - ) - self.bot.sendDocument( - chat_id=self._chat_id, document=document, caption=caption) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending document") - - def send_location(self, gps): - """Send a location.""" - import telegram - latitude = float(gps.get(ATTR_LATITUDE, 0.0)) - longitude = float(gps.get(ATTR_LONGITUDE, 0.0)) - - # Send location - try: - self.bot.sendLocation( - chat_id=self._chat_id, latitude=latitude, longitude=longitude) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending location") + _LOGGER.debug('TELEGRAM NOTIFIER calling %s.send_message with %s', + DOMAIN, service_data) + return self.hass.services.call( + DOMAIN, 'send_message', service_data=service_data) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 200c4227f4d..235217d1942 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -1,45 +1,186 @@ -"""Component to receive telegram messages.""" -import asyncio -import logging +""" +Component to send and receive Telegram messages. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/telegram_bot/ +""" +import asyncio +import io +from functools import partial +from ipaddress import ip_network +import logging +import os + +import requests import voluptuous as vol +from homeassistant.components.notify import ( + ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_PLATFORM, CONF_API_KEY, CONF_TIMEOUT, ATTR_LATITUDE, ATTR_LONGITUDE) import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_PLATFORM, CONF_API_KEY -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import discovery, config_per_platform from homeassistant.setup import async_prepare_setup_platform DOMAIN = 'telegram_bot' +REQUIREMENTS = ['python-telegram-bot==5.3.1'] _LOGGER = logging.getLogger(__name__) EVENT_TELEGRAM_COMMAND = 'telegram_command' EVENT_TELEGRAM_TEXT = 'telegram_text' +EVENT_TELEGRAM_CALLBACK = 'telegram_callback' +PARSER_MD = 'markdown' +PARSER_HTML = 'html' +ATTR_TEXT = 'text' ATTR_COMMAND = 'command' ATTR_USER_ID = 'user_id' ATTR_ARGS = 'args' +ATTR_MSG = 'message' +ATTR_CHAT_INSTANCE = 'chat_instance' +ATTR_CHAT_ID = 'chat_id' +ATTR_MSGID = 'id' ATTR_FROM_FIRST = 'from_first' ATTR_FROM_LAST = 'from_last' -ATTR_TEXT = 'text' - +ATTR_SHOW_ALERT = 'show_alert' +ATTR_MESSAGEID = 'message_id' +ATTR_PARSER = 'parse_mode' +ATTR_DISABLE_NOTIF = 'disable_notification' +ATTR_DISABLE_WEB_PREV = 'disable_web_page_preview' +ATTR_REPLY_TO_MSGID = 'reply_to_message_id' +ATTR_REPLYMARKUP = 'reply_markup' +ATTR_CALLBACK_QUERY = 'callback_query' +ATTR_CALLBACK_QUERY_ID = 'callback_query_id' +ATTR_TARGET = 'target' +ATTR_KEYBOARD = 'keyboard' +ATTR_KEYBOARD_INLINE = 'inline_keyboard' +ATTR_URL = 'url' +ATTR_FILE = 'file' +ATTR_CAPTION = 'caption' +ATTR_USERNAME = 'username' +ATTR_PASSWORD = 'password' CONF_ALLOWED_CHAT_IDS = 'allowed_chat_ids' +CONF_TRUSTED_NETWORKS = 'trusted_networks' +DEFAULT_TRUSTED_NETWORKS = [ + ip_network('149.154.167.197/32'), + ip_network('149.154.167.198/31'), + ip_network('149.154.167.200/29'), + ip_network('149.154.167.208/28'), + ip_network('149.154.167.224/29'), + ip_network('149.154.167.232/31') +] -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_ALLOWED_CHAT_IDS): - vol.All(cv.ensure_list, [cv.positive_int]) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PLATFORM): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ALLOWED_CHAT_IDS): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Optional(ATTR_PARSER, default=PARSER_MD): cv.string, + vol.Optional(CONF_TRUSTED_NETWORKS, default=DEFAULT_TRUSTED_NETWORKS): + vol.All(cv.ensure_list, [ip_network]) + }) }, extra=vol.ALLOW_EXTRA) +BASE_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.positive_int]), + vol.Optional(ATTR_PARSER): cv.string, + vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, + vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, + vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, +}, extra=vol.ALLOW_EXTRA) +SERVICE_SEND_MESSAGE = 'send_message' +SERVICE_SCHEMA_SEND_MESSAGE = BASE_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MESSAGE): cv.template, + vol.Optional(ATTR_TITLE): cv.template, +}) +SERVICE_SEND_PHOTO = 'send_photo' +SERVICE_SEND_DOCUMENT = 'send_document' +SERVICE_SCHEMA_SEND_FILE = BASE_SERVICE_SCHEMA.extend({ + vol.Optional(ATTR_URL): cv.string, + vol.Optional(ATTR_FILE): cv.string, + vol.Optional(ATTR_CAPTION): cv.string, + vol.Optional(ATTR_USERNAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, +}) +SERVICE_SEND_LOCATION = 'send_location' +SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_LONGITUDE): float, + vol.Required(ATTR_LATITUDE): float, +}) +SERVICE_EDIT_MESSAGE = 'edit_message' +SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend({ + vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_CHAT_ID): cv.positive_int, +}) +SERVICE_EDIT_CAPTION = 'edit_caption' +SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema({ + vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_CHAT_ID): cv.positive_int, + vol.Required(ATTR_CAPTION): cv.string, + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, +}, extra=vol.ALLOW_EXTRA) +SERVICE_EDIT_REPLYMARKUP = 'edit_replymarkup' +SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema({ + vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_CHAT_ID): cv.positive_int, + vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list, +}, extra=vol.ALLOW_EXTRA) +SERVICE_ANSWER_CALLBACK_QUERY = 'answer_callback_query' +SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema({ + vol.Required(ATTR_MESSAGE): cv.template, + vol.Required(ATTR_CALLBACK_QUERY_ID): cv.positive_int, + vol.Optional(ATTR_SHOW_ALERT): cv.boolean, +}, extra=vol.ALLOW_EXTRA) + +SERVICE_MAP = { + SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, + SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, + SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, + SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION, + SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP, + SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, +} + + +def load_data(url=None, file=None, username=None, password=None): + """Load photo/document into ByteIO/File container from a source.""" + try: + if url is not None: + # Load photo from URL + if username is not None and password is not None: + req = requests.get(url, auth=(username, password), timeout=15) + else: + req = requests.get(url, timeout=15) + return io.BytesIO(req.content) + + elif file is not None: + # Load photo from file + return open(file, "rb") + else: + _LOGGER.warning("Can't load photo. No photo found in params!") + + except OSError as error: + _LOGGER.error("Can't load photo into ByteIO: %s", error) + + return None + @asyncio.coroutine def async_setup(hass, config): - """Set up the telegram bot component.""" + """Set up the Telegram bot component.""" + conf = config[DOMAIN] + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml')) + @asyncio.coroutine def async_setup_platform(p_type, p_config=None, discovery_info=None): - """Set up a telegram bot platform.""" + """Set up a Telegram bot platform.""" platform = yield from async_prepare_setup_platform( hass, config, DOMAIN, p_type) @@ -48,20 +189,10 @@ def async_setup(hass, config): return _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) - try: - if hasattr(platform, 'async_setup_platform'): - notify_service = yield from \ - platform.async_setup_platform(hass, p_config, - discovery_info) - elif hasattr(platform, 'setup_platform'): - notify_service = yield from hass.loop.run_in_executor( - None, platform.setup_platform, hass, p_config, - discovery_info) - else: - raise HomeAssistantError("Invalid Telegram bot platform") - - if notify_service is None: + receiver_service = yield from \ + platform.async_setup_platform(hass, p_config, discovery_info) + if receiver_service is None: _LOGGER.error( "Failed to initialize Telegram bot %s", p_type) return @@ -70,22 +201,275 @@ def async_setup(hass, config): _LOGGER.exception('Error setting up platform %s', p_type) return - setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config - in config_per_platform(config, DOMAIN)] + notify_service = TelegramNotificationService( + hass, + p_config.get(CONF_API_KEY), + p_config.get(CONF_ALLOWED_CHAT_IDS), + p_config.get(ATTR_PARSER) + ) - if setup_tasks: - yield from asyncio.wait(setup_tasks, loop=hass.loop) + @asyncio.coroutine + def async_send_telegram_message(service): + """Handle sending Telegram Bot message service calls.""" + def _render_template_attr(data, attribute): + attribute_templ = data.get(attribute) + if attribute_templ: + attribute_templ.hass = hass + data[attribute] = attribute_templ.async_render() - @asyncio.coroutine - def async_platform_discovered(platform, info): - """Handle the loading of a platform.""" - yield from async_setup_platform(platform, discovery_info=info) + msgtype = service.service + kwargs = dict(service.data) + _render_template_attr(kwargs, ATTR_MESSAGE) + _render_template_attr(kwargs, ATTR_TITLE) + _LOGGER.debug('NEW telegram_message "%s": %s', msgtype, kwargs) - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + if msgtype == SERVICE_SEND_MESSAGE: + yield from hass.async_add_job( + partial(notify_service.send_message, **kwargs)) + elif msgtype == SERVICE_SEND_PHOTO: + yield from hass.async_add_job( + partial(notify_service.send_file, True, **kwargs)) + elif msgtype == SERVICE_SEND_DOCUMENT: + yield from hass.async_add_job( + partial(notify_service.send_file, False, **kwargs)) + elif msgtype == SERVICE_SEND_LOCATION: + yield from hass.async_add_job( + partial(notify_service.send_location, **kwargs)) + elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: + yield from hass.async_add_job( + partial(notify_service.answer_callback_query, **kwargs)) + else: + yield from hass.async_add_job( + partial(notify_service.edit_message, msgtype, **kwargs)) + + # Register notification services + for service_notif, schema in SERVICE_MAP.items(): + hass.services.async_register( + DOMAIN, service_notif, async_send_telegram_message, + descriptions.get(service_notif), schema=schema) + + return True + + yield from async_setup_platform(conf.get(CONF_PLATFORM), conf) return True +class TelegramNotificationService: + """Implement the notification services for the Telegram Bot domain.""" + + def __init__(self, hass, api_key, allowed_chat_ids, parser): + """Initialize the service.""" + from telegram import Bot + from telegram.parsemode import ParseMode + + self.allowed_chat_ids = allowed_chat_ids + self._default_user = self.allowed_chat_ids[0] + self._last_message_id = {user: None for user in self.allowed_chat_ids} + self._parsers = {PARSER_HTML: ParseMode.HTML, + PARSER_MD: ParseMode.MARKDOWN} + self._parse_mode = self._parsers.get(parser) + self.bot = Bot(token=api_key) + self.hass = hass + + def _get_msg_ids(self, msg_data, chat_id): + """Get the message id to edit. + + This can be one of (message_id, inline_message_id) from a msg dict, + returning a tuple. + **You can use 'last' as message_id** to edit + the last sended message in the chat_id. + """ + message_id = inline_message_id = None + if ATTR_MESSAGEID in msg_data: + message_id = msg_data[ATTR_MESSAGEID] + if (isinstance(message_id, str) and (message_id == 'last') and + (self._last_message_id[chat_id] is not None)): + message_id = self._last_message_id[chat_id] + else: + inline_message_id = msg_data['inline_message_id'] + return message_id, inline_message_id + + def _get_target_chat_ids(self, target): + """Validate chat_id targets or return default target (fist defined). + + :param target: optional list of strings or ints (['12234'] or [12234]) + :return list of chat_id targets (integers) + """ + if target is not None: + if isinstance(target, int): + if target in self.allowed_chat_ids: + return [target] + _LOGGER.warning('BAD TARGET "%s", using default: %s', + target, self._default_user) + else: + try: + chat_ids = [int(t) for t in target + if int(t) in self.allowed_chat_ids] + if len(chat_ids) > 0: + return chat_ids + _LOGGER.warning('ALL BAD TARGETS: "%s"', target) + except (ValueError, TypeError): + _LOGGER.warning('BAD TARGET DATA "%s", using default: %s', + target, self._default_user) + return [self._default_user] + + def _get_msg_kwargs(self, data): + """Get parameters in message data kwargs.""" + def _make_row_of_kb(row_keyboard): + """Make a list of InlineKeyboardButtons from a list of tuples. + + :param row_keyboard: [(text_b1, data_callback_b1), + (text_b2, data_callback_b2), ...] + """ + from telegram import InlineKeyboardButton + if isinstance(row_keyboard, str): + return [InlineKeyboardButton( + key.strip()[1:].upper(), callback_data=key) + for key in row_keyboard.split(",")] + elif isinstance(row_keyboard, list): + return [InlineKeyboardButton( + text_btn, callback_data=data_btn) + for text_btn, data_btn in row_keyboard] + else: + raise ValueError(str(row_keyboard)) + + # Defaults + params = { + ATTR_PARSER: self._parse_mode, + ATTR_DISABLE_NOTIF: False, + ATTR_DISABLE_WEB_PREV: None, + ATTR_REPLY_TO_MSGID: None, + ATTR_REPLYMARKUP: None, + CONF_TIMEOUT: None + } + if data is not None: + if ATTR_PARSER in data: + params[ATTR_PARSER] = self._parsers.get( + data[ATTR_PARSER], self._parse_mode) + if CONF_TIMEOUT in data: + params[CONF_TIMEOUT] = data[CONF_TIMEOUT] + if ATTR_DISABLE_NOTIF in data: + params[ATTR_DISABLE_NOTIF] = data[ATTR_DISABLE_NOTIF] + if ATTR_DISABLE_WEB_PREV in data: + params[ATTR_DISABLE_WEB_PREV] = data[ATTR_DISABLE_WEB_PREV] + if ATTR_REPLY_TO_MSGID in data: + params[ATTR_REPLY_TO_MSGID] = data[ATTR_REPLY_TO_MSGID] + # Keyboards: + if ATTR_KEYBOARD in data: + from telegram import ReplyKeyboardMarkup + keys = data.get(ATTR_KEYBOARD) + keys = keys if isinstance(keys, list) else [keys] + params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( + [[key.strip() for key in row.split(",")] for row in keys]) + elif ATTR_KEYBOARD_INLINE in data: + from telegram import InlineKeyboardMarkup + keys = data.get(ATTR_KEYBOARD_INLINE) + keys = keys if isinstance(keys, list) else [keys] + params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup( + [_make_row_of_kb(row) for row in keys]) + return params + + def _send_msg(self, func_send, msg_error, *args_rep, **kwargs_rep): + """Send one message.""" + from telegram.error import TelegramError + try: + out = func_send(*args_rep, **kwargs_rep) + if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID): + chat_id = out.chat_id + self._last_message_id[chat_id] = out[ATTR_MESSAGEID] + _LOGGER.debug('LAST MSG ID: %s (from chat_id %s)', + self._last_message_id, chat_id) + elif not isinstance(out, bool): + _LOGGER.warning('UPDATE LAST MSG??: out_type:%s, out=%s', + type(out), out) + return out + except TelegramError: + _LOGGER.exception(msg_error) + + def send_message(self, message="", target=None, **kwargs): + """Send a message to one or multiple pre-allowed chat_ids.""" + title = kwargs.get(ATTR_TITLE) + text = '{}\n{}'.format(title, message) if title else message + params = self._get_msg_kwargs(kwargs) + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug('send_message in chat_id %s with params: %s', + chat_id, params) + self._send_msg(self.bot.sendMessage, + "Error sending message", + chat_id, text, **params) + + def edit_message(self, type_edit, chat_id=None, **kwargs): + """Edit a previously sent message.""" + chat_id = self._get_target_chat_ids(chat_id)[0] + message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) + params = self._get_msg_kwargs(kwargs) + _LOGGER.debug('edit_message %s in chat_id %s with params: %s', + message_id or inline_message_id, chat_id, params) + if type_edit == SERVICE_EDIT_MESSAGE: + message = kwargs.get(ATTR_MESSAGE) + title = kwargs.get(ATTR_TITLE) + text = '{}\n{}'.format(title, message) if title else message + _LOGGER.debug('editing message w/id %s.', + message_id or inline_message_id) + return self._send_msg(self.bot.editMessageText, + "Error editing text message", + text, chat_id=chat_id, message_id=message_id, + inline_message_id=inline_message_id, + **params) + elif type_edit == SERVICE_EDIT_CAPTION: + func_send = self.bot.editMessageCaption + params[ATTR_CAPTION] = kwargs.get(ATTR_CAPTION) + else: + func_send = self.bot.editMessageReplyMarkup + return self._send_msg(func_send, + "Error editing message attributes", + chat_id=chat_id, message_id=message_id, + inline_message_id=inline_message_id, + **params) + + def answer_callback_query(self, message, callback_query_id, + show_alert=False, **kwargs): + """Answer a callback originated with a press in an inline keyboard.""" + params = self._get_msg_kwargs(kwargs) + _LOGGER.debug('answer_callback_query w/callback_id %s: %s, alert: %s.', + callback_query_id, message, show_alert) + self._send_msg(self.bot.answerCallbackQuery, + "Error sending answer callback query", + callback_query_id, + text=message, show_alert=show_alert, **params) + + def send_file(self, is_photo=True, target=None, **kwargs): + """Send a photo or a document.""" + file = load_data( + url=kwargs.get(ATTR_URL), + file=kwargs.get(ATTR_FILE), + username=kwargs.get(ATTR_USERNAME), + password=kwargs.get(ATTR_PASSWORD), + ) + params = self._get_msg_kwargs(kwargs) + caption = kwargs.get(ATTR_CAPTION) + func_send = self.bot.sendPhoto if is_photo else self.bot.sendDocument + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug('send file %s to chat_id %s. Caption: %s.', + file, chat_id, caption) + self._send_msg(func_send, "Error sending file", + chat_id, file, caption=caption, **params) + + def send_location(self, latitude, longitude, target=None, **kwargs): + """Send a location.""" + latitude = float(latitude) + longitude = float(longitude) + params = self._get_msg_kwargs(kwargs) + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug('send location %s/%s to chat_id %s.', + latitude, longitude, chat_id) + self._send_msg(self.bot.sendLocation, + "Error sending location", + chat_id=chat_id, + latitude=latitude, longitude=longitude, **params) + + class BaseTelegramBotEntity: """The base class for the telegram bot.""" @@ -94,32 +478,56 @@ class BaseTelegramBotEntity: self.allowed_chat_ids = allowed_chat_ids self.hass = hass + def _get_message_data(self, msg_data): + if (not msg_data or + ('text' not in msg_data and 'data' not in msg_data) or + 'from' not in msg_data or + msg_data['from'].get('id') not in self.allowed_chat_ids): + # Message is not correct. + _LOGGER.error("Incoming message does not have required data (%s)", + msg_data) + return None + + return { + ATTR_USER_ID: msg_data['from']['id'], + ATTR_FROM_FIRST: msg_data['from']['first_name'], + ATTR_FROM_LAST: msg_data['from']['last_name'] + } + def process_message(self, data): """Check for basic message rules and fire an event if message is ok.""" - data = data.get('message') + if ATTR_MSG in data: + event = EVENT_TELEGRAM_COMMAND + data = data.get(ATTR_MSG) + event_data = self._get_message_data(data) + if event_data is None: + return False - if (not data or - 'from' not in data or - 'text' not in data or - data['from'].get('id') not in self.allowed_chat_ids): - _LOGGER.error("Incoming message does not have required data") - return False + if data[ATTR_TEXT][0] == '/': + pieces = data[ATTR_TEXT].split(' ') + event_data[ATTR_COMMAND] = pieces[0] + event_data[ATTR_ARGS] = pieces[1:] + else: + event_data[ATTR_TEXT] = data[ATTR_TEXT] + event = EVENT_TELEGRAM_TEXT - event = EVENT_TELEGRAM_COMMAND - event_data = { - ATTR_USER_ID: data['from']['id'], - ATTR_FROM_FIRST: data['from'].get('first_name', 'N/A'), - ATTR_FROM_LAST: data['from'].get('last_name', 'N/A')} + self.hass.bus.async_fire(event, event_data) + return True + elif ATTR_CALLBACK_QUERY in data: + event = EVENT_TELEGRAM_CALLBACK + data = data.get(ATTR_CALLBACK_QUERY) + event_data = self._get_message_data(data) + if event_data is None: + return False - if data['text'][0] == '/': - pieces = data['text'].split(' ') - event_data[ATTR_COMMAND] = pieces[0] - event_data[ATTR_ARGS] = pieces[1:] + event_data[ATTR_DATA] = data[ATTR_DATA] + event_data[ATTR_MSG] = data[ATTR_MSG] + event_data[ATTR_CHAT_INSTANCE] = data[ATTR_CHAT_INSTANCE] + event_data[ATTR_MSGID] = data[ATTR_MSGID] + self.hass.bus.async_fire(event, event_data) + return True else: - event_data[ATTR_TEXT] = data['text'] - event = EVENT_TELEGRAM_TEXT - - self.hass.bus.async_fire(event, event_data) - - return True + # Some other thing... + _LOGGER.warning('SOME OTHER THING RECEIVED --> "%s"', data) + return False diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 8ae0a07a480..161c4e356a2 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -11,19 +11,15 @@ import logging import async_timeout from aiohttp.client_exceptions import ClientError -from homeassistant.components.telegram_bot import CONF_ALLOWED_CHAT_IDS, \ - BaseTelegramBotEntity, PLATFORM_SCHEMA -from homeassistant.const import EVENT_HOMEASSISTANT_START, \ - EVENT_HOMEASSISTANT_STOP, CONF_API_KEY +from homeassistant.components.telegram_bot import ( + CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_API_KEY) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-telegram-bot==5.3.1'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA - @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml new file mode 100644 index 00000000000..4ce932d5f41 --- /dev/null +++ b/homeassistant/components/telegram_bot/services.yaml @@ -0,0 +1,227 @@ +send_message: + description: Send a notification + + fields: + message: + description: Message body of the notification. + example: The garage door has been open for 10 minutes. + + title: + description: Optional title for your notification. Will be composed as '%title\n%message' + example: 'Your Garage Door Friend' + + target: + description: An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + + parse_mode: + description: "Parser for the message text: `html` or `markdown`." + example: 'html' + + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + + disable_web_page_preview: + description: Disables link previews for links in the message. + example: true + + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +send_photo: + description: Send a photo + + fields: + url: + description: Remote path to an image. + example: 'http://example.org/path/to/the/image.png' + + file: + description: Local path to an image. + example: '/path/to/the/image.png' + + caption: + description: The title of the image. + example: 'My image' + + username: + description: Username for a URL which require HTTP basic authentication. + example: myuser + + password: + description: Password for a URL which require HTTP basic authentication. + example: myuser_pwd + + target: + description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +send_document: + description: Send a document + + fields: + url: + description: Remote path to a document. + example: 'http://example.org/path/to/the/document.odf' + + file: + description: Local path to a document. + example: '/tmp/whatever.odf' + + caption: + description: The title of the document. + example: Document Title xy + + username: + description: Username for a URL which require HTTP basic authentication. + example: myuser + + password: + description: Password for a URL which require HTTP basic authentication. + example: myuser_pwd + + target: + description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +send_location: + description: Send a location + + fields: + latitude: + description: The latitude to send. + example: -15.123 + + longitude: + description: The longitude to send. + example: 38.123 + + target: + description: An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +edit_message: + description: Edit a previusly sent message. + + fields: + message_id: + description: id of the message to edit. + example: '{{ trigger.event.data.message.message_id }}' + + chat_id: + description: The chat_id where to edit the message. + example: 12345 + + message: + description: Message body of the notification. + example: The garage door has been open for 10 minutes. + + title: + description: Optional title for your notification. Will be composed as '%title\n%message' + example: 'Your Garage Door Friend' + + parse_mode: + description: "Parser for the message text: `html` or `markdown`." + example: 'html' + + disable_web_page_preview: + description: Disables link previews for links in the message. + example: true + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +edit_caption: + description: Edit the caption of a previusly sent message. + + fields: + message_id: + description: id of the message to edit. + example: '{{ trigger.event.data.message.message_id }}' + + chat_id: + description: The chat_id where to edit the caption. + example: 12345 + + caption: + description: Message body of the notification. + example: The garage door has been open for 10 minutes. + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +edit_replymarkup: + description: Edit the inline keyboard of a previusly sent message. + + fields: + message_id: + description: id of the message to edit. + example: '{{ trigger.event.data.message.message_id }}' + + chat_id: + description: The chat_id where to edit the reply_markup. + example: 12345 + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +answer_callback_query: + description: Respond to a callback query originated by clicking on an online keyboard button. The answer will be displayed to the user as a notification at the top of the chat screen or as an alert. + + fields: + message: + description: Unformatted text message body of the notification. + example: "OK, I'm listening" + + callback_query_id: + description: Unique id of the callback response. + example: '{{ trigger.event.data.id }}' + + show_alert: + description: Show a permanent notification. + example: true diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index d647fab490b..690340fc378 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -5,60 +5,52 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/telegram_bot.webhooks/ """ import asyncio +import datetime as dt import logging -from ipaddress import ip_network -import voluptuous as vol - -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED) -import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView -from homeassistant.components.telegram_bot import CONF_ALLOWED_CHAT_IDS, \ - BaseTelegramBotEntity, PLATFORM_SCHEMA -from homeassistant.const import CONF_API_KEY from homeassistant.components.http.util import get_real_ip +from homeassistant.components.telegram_bot import ( + CONF_ALLOWED_CHAT_IDS, CONF_TRUSTED_NETWORKS, BaseTelegramBotEntity) +from homeassistant.const import ( + CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, + HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED) DEPENDENCIES = ['http'] -REQUIREMENTS = ['python-telegram-bot==5.3.1'] _LOGGER = logging.getLogger(__name__) TELEGRAM_HANDLER_URL = '/api/telegram_webhooks' REMOVE_HANDLER_URL = '' -CONF_TRUSTED_NETWORKS = 'trusted_networks' -DEFAULT_TRUSTED_NETWORKS = [ - ip_network('149.154.167.197/32'), - ip_network('149.154.167.198/31'), - ip_network('149.154.167.200/29'), - ip_network('149.154.167.208/28'), - ip_network('149.154.167.224/29'), - ip_network('149.154.167.232/31') -] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_TRUSTED_NETWORKS, default=DEFAULT_TRUSTED_NETWORKS): - vol.All(cv.ensure_list, [ip_network]) -}) - - -def setup_platform(hass, config, async_add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Telegram webhooks platform.""" import telegram bot = telegram.Bot(config[CONF_API_KEY]) - current_status = bot.getWebhookInfo() - handler_url = '{0}{1}'.format( - hass.config.api.base_url, TELEGRAM_HANDLER_URL) + current_status = yield from hass.async_add_job(bot.getWebhookInfo) + + # Some logging of Bot current status: + last_error_date = getattr(current_status, 'last_error_date', None) + if (last_error_date is not None) and (isinstance(last_error_date, int)): + last_error_date = dt.datetime.fromtimestamp(last_error_date) + _LOGGER.info("telegram webhook last_error_date: %s. Status: %s", + last_error_date, current_status) + else: + _LOGGER.debug("telegram webhook Status: %s", current_status) + handler_url = '{0}{1}'.format(hass.config.api.base_url, + TELEGRAM_HANDLER_URL) if current_status and current_status['url'] != handler_url: - if bot.setWebhook(handler_url): + result = yield from hass.async_add_job(bot.setWebhook, handler_url) + if result: _LOGGER.info("Set new telegram webhook %s", handler_url) else: _LOGGER.error("Set telegram webhook failed %s", handler_url) return False - hass.bus.listen_once( + hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, lambda event: bot.setWebhook(REMOVE_HANDLER_URL)) hass.http.register_view(BotPushReceiver( diff --git a/requirements_all.txt b/requirements_all.txt index 624b1f22b06..27ae45c9ddf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -674,9 +674,7 @@ python-roku==3.1.3 # homeassistant.components.sensor.synologydsm python-synology==0.1.0 -# homeassistant.components.notify.telegram -# homeassistant.components.telegram_bot.polling -# homeassistant.components.telegram_bot.webhooks +# homeassistant.components.telegram_bot python-telegram-bot==5.3.1 # homeassistant.components.sensor.twitch From 89d950c73af283ec84e63dcaf85ccc8cb3cd5465 Mon Sep 17 00:00:00 2001 From: Nuno Sousa Date: Wed, 10 May 2017 05:54:38 +0100 Subject: [PATCH 061/135] Add password parameter to uvc component (#7499) --- homeassistant/components/camera/uvc.py | 20 +++++++-------- tests/components/camera/test_uvc.py | 34 +++++++++----------------- 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 3840a8a90b1..c0a8039ee64 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -20,12 +20,15 @@ _LOGGER = logging.getLogger(__name__) CONF_NVR = 'nvr' CONF_KEY = 'key' +CONF_PASSWORD = 'password' +DEFAULT_PASSWORD = 'ubnt' DEFAULT_PORT = 7080 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NVR): cv.string, vol.Required(CONF_KEY): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) @@ -34,6 +37,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Discover cameras on a Unifi NVR.""" addr = config[CONF_NVR] key = config[CONF_KEY] + password = config[CONF_PASSWORD] port = config[CONF_PORT] from uvcclient import nvr @@ -59,7 +63,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([UnifiVideoCamera(nvrconn, camera[identifier], - camera['name']) + camera['name'], + password) for camera in cameras]) return True @@ -67,12 +72,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class UnifiVideoCamera(Camera): """A Ubiquiti Unifi Video Camera.""" - def __init__(self, nvr, uuid, name): + def __init__(self, nvr, uuid, name, password): """Initialize an Unifi camera.""" super(UnifiVideoCamera, self).__init__() self._nvr = nvr self._uuid = uuid self._name = name + self._password = password self.is_streaming = False self._connect_addr = None self._camera = None @@ -102,7 +108,6 @@ class UnifiVideoCamera(Camera): def _login(self): """Login to the camera.""" from uvcclient import camera as uvc_camera - from uvcclient import store as uvc_store caminfo = self._nvr.get_camera(self._uuid) if self._connect_addr: @@ -110,13 +115,6 @@ class UnifiVideoCamera(Camera): else: addrs = [caminfo['host'], caminfo['internalHost']] - store = uvc_store.get_info_store() - password = store.get_camera_password(self._uuid) - if password is None: - _LOGGER.debug("Logging into camera %(name)s with default password", - dict(name=self._name)) - password = 'ubnt' - if self._nvr.server_version >= (3, 2, 0): client_cls = uvc_camera.UVCCameraClientV320 else: @@ -126,7 +124,7 @@ class UnifiVideoCamera(Camera): for addr in addrs: try: camera = client_cls( - addr, caminfo['username'], password) + addr, caminfo['username'], self._password) camera.login() _LOGGER.debug("Logged into UVC camera %(name)s via %(addr)s", dict(name=self._name, addr=addr)) diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index f949d1e728e..ad7ee5f5bcb 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -31,6 +31,7 @@ class TestUVCSetup(unittest.TestCase): config = { 'platform': 'uvc', 'nvr': 'foo', + 'password': 'bar', 'port': 123, 'key': 'secret', } @@ -58,8 +59,8 @@ class TestUVCSetup(unittest.TestCase): mock_remote.call_args, mock.call('foo', 123, 'secret') ) mock_uvc.assert_has_calls([ - mock.call(mock_remote.return_value, 'id1', 'Front'), - mock.call(mock_remote.return_value, 'id2', 'Back'), + mock.call(mock_remote.return_value, 'id1', 'Front', 'bar'), + mock.call(mock_remote.return_value, 'id2', 'Back', 'bar'), ]) @mock.patch('uvcclient.nvr.UVCRemote') @@ -86,8 +87,8 @@ class TestUVCSetup(unittest.TestCase): mock_remote.call_args, mock.call('foo', 7080, 'secret') ) mock_uvc.assert_has_calls([ - mock.call(mock_remote.return_value, 'id1', 'Front'), - mock.call(mock_remote.return_value, 'id2', 'Back'), + mock.call(mock_remote.return_value, 'id1', 'Front', 'ubnt'), + mock.call(mock_remote.return_value, 'id2', 'Back', 'ubnt'), ]) @mock.patch('uvcclient.nvr.UVCRemote') @@ -114,8 +115,8 @@ class TestUVCSetup(unittest.TestCase): mock_remote.call_args, mock.call('foo', 7080, 'secret') ) mock_uvc.assert_has_calls([ - mock.call(mock_remote.return_value, 'one', 'Front'), - mock.call(mock_remote.return_value, 'two', 'Back'), + mock.call(mock_remote.return_value, 'one', 'Front', 'ubnt'), + mock.call(mock_remote.return_value, 'two', 'Back', 'ubnt'), ]) @mock.patch.object(uvc, 'UnifiVideoCamera') @@ -156,7 +157,9 @@ class TestUVC(unittest.TestCase): self.nvr = mock.MagicMock() self.uuid = 'uuid' self.name = 'name' - self.uvc = uvc.UnifiVideoCamera(self.nvr, self.uuid, self.name) + self.password = 'seekret' + self.uvc = uvc.UnifiVideoCamera(self.nvr, self.uuid, self.name, + self.password) self.nvr.get_camera.return_value = { 'model': 'UVC Fake', 'recordingSettings': { @@ -179,7 +182,6 @@ class TestUVC(unittest.TestCase): @mock.patch('uvcclient.camera.UVCCameraClientV320') def test_login(self, mock_camera, mock_store): """"Test the login.""" - mock_store.return_value.get_camera_password.return_value = 'seekret' self.uvc._login() self.assertEqual(mock_camera.call_count, 1) self.assertEqual( @@ -192,7 +194,6 @@ class TestUVC(unittest.TestCase): @mock.patch('uvcclient.camera.UVCCameraClient') def test_login_v31x(self, mock_camera, mock_store): """Test login with v3.1.x server.""" - mock_store.return_value.get_camera_password.return_value = 'seekret' self.nvr.server_version = (3, 1, 3) self.uvc._login() self.assertEqual(mock_camera.call_count, 1) @@ -202,19 +203,6 @@ class TestUVC(unittest.TestCase): self.assertEqual(mock_camera.return_value.login.call_count, 1) self.assertEqual(mock_camera.return_value.login.call_args, mock.call()) - @mock.patch('uvcclient.store.get_info_store') - @mock.patch('uvcclient.camera.UVCCameraClientV320') - def test_login_no_password(self, mock_camera, mock_store): - """"Test the login with no password.""" - mock_store.return_value.get_camera_password.return_value = None - self.uvc._login() - self.assertEqual(mock_camera.call_count, 1) - self.assertEqual( - mock_camera.call_args, mock.call('host-a', 'admin', 'ubnt') - ) - self.assertEqual(mock_camera.return_value.login.call_count, 1) - self.assertEqual(mock_camera.return_value.login.call_args, mock.call()) - @mock.patch('uvcclient.store.get_info_store') @mock.patch('uvcclient.camera.UVCCameraClientV320') def test_login_tries_both_addrs_and_caches(self, mock_camera, mock_store): @@ -239,7 +227,7 @@ class TestUVC(unittest.TestCase): self.uvc._login() self.assertEqual(mock_camera.call_count, 1) self.assertEqual( - mock_camera.call_args, mock.call('host-b', 'admin', 'ubnt') + mock_camera.call_args, mock.call('host-b', 'admin', 'seekret') ) self.assertEqual(mock_camera.return_value.login.call_count, 1) self.assertEqual(mock_camera.return_value.login.call_args, mock.call()) From 216199556a7d263321762cbd1a952ec98a7aa9b0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 10 May 2017 06:56:17 +0200 Subject: [PATCH 062/135] Don't interact with hass directly (#7099) --- .../components/binary_sensor/mystrom.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 homeassistant/components/binary_sensor/mystrom.py diff --git a/homeassistant/components/binary_sensor/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py new file mode 100644 index 00000000000..c551a8c4efe --- /dev/null +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -0,0 +1,95 @@ +""" +Support for the myStrom buttons. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.mystrom/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import (BinarySensorDevice, DOMAIN) +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up myStrom Binary Sensor.""" + hass.http.register_view(MyStromView(async_add_devices)) + + return True + + +class MyStromView(HomeAssistantView): + """View to handle requests from myStrom buttons.""" + + url = '/api/mystrom' + name = 'api:mystrom' + + def __init__(self, add_devices): + """Initialize the myStrom URL endpoint.""" + self.buttons = {} + self.add_devices = add_devices + + @asyncio.coroutine + def get(self, request): + """The GET request received from a myStrom button.""" + res = yield from self._handle(request.app['hass'], request.GET) + return res + + @asyncio.coroutine + def _handle(self, hass, data): + """Handle requests to the myStrom endpoint.""" + button_action = list(data.keys())[0] + button_id = data[button_action] + entity_id = '{}.{}_{}'.format(DOMAIN, button_id, button_action) + + if button_action not in ['single', 'double', 'long', 'touch']: + _LOGGER.error( + "Received unidentified message from myStrom button: %s", data) + return ("Received unidentified message: {}".format(data), + HTTP_UNPROCESSABLE_ENTITY) + + if entity_id not in self.buttons: + _LOGGER.info("New myStrom button/action detected: %s/%s", + button_id, button_action) + self.buttons[entity_id] = MyStromBinarySensor( + '{}_{}'.format(button_id, button_action)) + hass.async_add_job(self.add_devices, [self.buttons[entity_id]]) + else: + new_state = True if self.buttons[entity_id].state == 'off' \ + else False + self.buttons[entity_id].async_on_update(new_state) + + +class MyStromBinarySensor(BinarySensorDevice): + """Representation of a myStrom button.""" + + def __init__(self, button_id): + """Initialize the myStrom Binary sensor.""" + self._button_id = button_id + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._button_id + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + def async_on_update(self, value): + """Receive an update.""" + self._state = value + self.hass.async_add_job(self.async_update_ha_state()) From 85e71fc785d094db42760bce907706eb6618367a Mon Sep 17 00:00:00 2001 From: Bas Schipper Date: Wed, 10 May 2017 07:36:33 +0200 Subject: [PATCH 063/135] Support for the PiFace Digital I/O module (#7494) * Added rpi_pfio component supporting the PiFace I/O module * Fixed some code style issues * Removed global listener * Update rpi_pfio.py --- .coveragerc | 3 + .../components/binary_sensor/rpi_pfio.py | 93 +++++++++++++++++++ homeassistant/components/rpi_pfio.py | 63 +++++++++++++ homeassistant/components/switch/rpi_pfio.py | 87 +++++++++++++++++ requirements_all.txt | 6 ++ 5 files changed, 252 insertions(+) create mode 100644 homeassistant/components/binary_sensor/rpi_pfio.py create mode 100644 homeassistant/components/rpi_pfio.py create mode 100644 homeassistant/components/switch/rpi_pfio.py diff --git a/.coveragerc b/.coveragerc index bbc40156cb5..f54cc6fbafe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -91,6 +91,9 @@ omit = homeassistant/components/rpi_gpio.py homeassistant/components/*/rpi_gpio.py + homeassistant/components/rpi_pfio.py + homeassistant/components/*/rpi_pfio.py + homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py diff --git a/homeassistant/components/binary_sensor/rpi_pfio.py b/homeassistant/components/binary_sensor/rpi_pfio.py new file mode 100644 index 00000000000..92d02067dfc --- /dev/null +++ b/homeassistant/components/binary_sensor/rpi_pfio.py @@ -0,0 +1,93 @@ +""" +Support for binary sensor using the PiFace Digital I/O module on a RPi. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rpi_pfio/ +""" +import logging + +import voluptuous as vol + +import homeassistant.components.rpi_pfio as rpi_pfio +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import DEVICE_DEFAULT_NAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_NAME = 'name' +ATTR_INVERT_LOGIC = 'invert_logic' +ATTR_SETTLE_TIME = 'settle_time' +CONF_PORTS = 'ports' + +DEFAULT_INVERT_LOGIC = False +DEFAULT_SETTLE_TIME = 20 + +DEPENDENCIES = ['rpi_pfio'] + +PORT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_NAME, default=None): cv.string, + vol.Optional(ATTR_SETTLE_TIME, default=DEFAULT_SETTLE_TIME): + cv.positive_int, + vol.Optional(ATTR_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_PORTS, default={}): vol.Schema({ + cv.positive_int: PORT_SCHEMA + }) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the PiFace Digital Input devices.""" + binary_sensors = [] + ports = config.get('ports') + for port, port_entity in ports.items(): + name = port_entity[ATTR_NAME] + settle_time = port_entity[ATTR_SETTLE_TIME] / 1000 + invert_logic = port_entity[ATTR_INVERT_LOGIC] + + binary_sensors.append(RPiPFIOBinarySensor( + hass, port, name, settle_time, invert_logic)) + add_devices(binary_sensors, True) + + rpi_pfio.activate_listener(hass) + + +class RPiPFIOBinarySensor(BinarySensorDevice): + """Represent a binary sensor that a PiFace Digital Input.""" + + def __init__(self, hass, port, name, settle_time, invert_logic): + """Initialize the RPi binary sensor.""" + self._port = port + self._name = name or DEVICE_DEFAULT_NAME + self._invert_logic = invert_logic + self._state = None + + def read_pfio(port): + """Read state from PFIO.""" + self._state = rpi_pfio.read_input(self._port) + self.schedule_update_ha_state() + + rpi_pfio.edge_detect(hass, self._port, read_pfio, settle_time) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state != self._invert_logic + + def update(self): + """Update the PFIO state.""" + self._state = rpi_pfio.read_input(self._port) diff --git a/homeassistant/components/rpi_pfio.py b/homeassistant/components/rpi_pfio.py new file mode 100644 index 00000000000..bf8fdccfab0 --- /dev/null +++ b/homeassistant/components/rpi_pfio.py @@ -0,0 +1,63 @@ +""" +Support for controlling the PiFace Digital I/O module on a RPi. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rpi_pfio/ +""" +import logging + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = ['pifacecommon==4.1.2', 'pifacedigitalio==3.0.5'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'rpi_pfio' + +DATA_PFIO_LISTENER = 'pfio_listener' + + +def setup(hass, config): + """Set up the Raspberry PI PFIO component.""" + import pifacedigitalio as PFIO + + pifacedigital = PFIO.PiFaceDigital() + hass.data[DATA_PFIO_LISTENER] = PFIO.InputEventListener(chip=pifacedigital) + + def cleanup_pfio(event): + """Stuff to do before stopping.""" + PFIO.deinit() + + def prepare_pfio(event): + """Stuff to do when home assistant starts.""" + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_pfio) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_pfio) + PFIO.init() + + return True + + +def write_output(port, value): + """Write a value to a PFIO.""" + import pifacedigitalio as PFIO + PFIO.digital_write(port, value) + + +def read_input(port): + """Read a value from a PFIO.""" + import pifacedigitalio as PFIO + return PFIO.digital_read(port) + + +def edge_detect(hass, port, event_callback, settle): + """Add detection for RISING and FALLING events.""" + import pifacedigitalio as PFIO + hass.data[DATA_PFIO_LISTENER].register( + port, PFIO.IODIR_BOTH, event_callback, settle_time=settle) + + +def activate_listener(hass): + """Activate the registered listener events.""" + hass.data[DATA_PFIO_LISTENER].activate() diff --git a/homeassistant/components/switch/rpi_pfio.py b/homeassistant/components/switch/rpi_pfio.py new file mode 100644 index 00000000000..6e50725b564 --- /dev/null +++ b/homeassistant/components/switch/rpi_pfio.py @@ -0,0 +1,87 @@ +""" +Allows to configure a switch using the PiFace Digital I/O module on a RPi. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.rpi_pfio/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA +import homeassistant.components.rpi_pfio as rpi_pfio +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.helpers.entity import ToggleEntity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['rpi_pfio'] + +ATTR_INVERT_LOGIC = 'invert_logic' +ATTR_NAME = 'name' +CONF_PORTS = 'ports' + +DEFAULT_INVERT_LOGIC = False + +PORT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_NAME, default=None): cv.string, + vol.Optional(ATTR_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_PORTS, default={}): vol.Schema({ + cv.positive_int: PORT_SCHEMA + }) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the PiFace Digital Output devices.""" + switches = [] + ports = config.get(CONF_PORTS) + for port, port_entity in ports.items(): + name = port_entity[ATTR_NAME] + invert_logic = port_entity[ATTR_INVERT_LOGIC] + + switches.append(RPiPFIOSwitch(port, name, invert_logic)) + add_devices(switches) + + +class RPiPFIOSwitch(ToggleEntity): + """Representation of a PiFace Digital Output.""" + + def __init__(self, port, name, invert_logic): + """Initialize the pin.""" + self._port = port + self._name = name or DEVICE_DEFAULT_NAME + self._invert_logic = invert_logic + self._state = False + rpi_pfio.write_output(self._port, 1 if self._invert_logic else 0) + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self): + """Turn the device on.""" + rpi_pfio.write_output(self._port, 0 if self._invert_logic else 1) + self._state = True + self.schedule_update_ha_state() + + def turn_off(self): + """Turn the device off.""" + rpi_pfio.write_output(self._port, 1 if self._invert_logic else 0) + self._state = False + self.schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 27ae45c9ddf..84cd1a50ce8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -429,6 +429,12 @@ pexpect==4.0.1 # homeassistant.components.light.hue phue==0.9 +# homeassistant.components.rpi_pfio +pifacecommon==4.1.2 + +# homeassistant.components.rpi_pfio +pifacedigitalio==3.0.5 + # homeassistant.components.light.piglow piglow==1.2.4 From 6e6a00021742971f06dcf50b1671593b5d182452 Mon Sep 17 00:00:00 2001 From: corneyl Date: Wed, 10 May 2017 10:45:33 +0200 Subject: [PATCH 064/135] Upgrade limitlessled to 1.0.7 (#7525) --- 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 1a1fe1cffd7..bb2fd5a60f3 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['limitlessled==1.0.5'] +REQUIREMENTS = ['limitlessled==1.0.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 84cd1a50ce8..4d352c9c22b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -351,7 +351,7 @@ libsoundtouch==0.3.0 liffylights==0.9.4 # homeassistant.components.light.limitlessled -limitlessled==1.0.5 +limitlessled==1.0.7 # homeassistant.components.media_player.liveboxplaytv liveboxplaytv==1.4.9 From 71b4afb78092591d219cab4ecb0391c2c5ea9eed Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 10 May 2017 12:06:57 +0200 Subject: [PATCH 065/135] Update docstrings and log messages (#7526) --- .../components/light/blinksticklight.py | 2 +- homeassistant/components/light/enocean.py | 5 +-- homeassistant/components/light/flux_led.py | 44 +++++++++---------- .../components/light/insteon_local.py | 4 +- homeassistant/components/light/insteon_plm.py | 16 +++---- homeassistant/components/light/isy994.py | 9 ++-- .../components/light/limitlessled.py | 1 - homeassistant/components/light/tradfri.py | 7 ++- 8 files changed, 45 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/light/blinksticklight.py b/homeassistant/components/light/blinksticklight.py index bbbde10ecc8..d6a6ef465a8 100644 --- a/homeassistant/components/light/blinksticklight.py +++ b/homeassistant/components/light/blinksticklight.py @@ -31,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Add device specified by serial number.""" + """Set up Blinkstick device specified by serial number.""" from blinkstick import blinkstick name = config.get(CONF_NAME) diff --git a/homeassistant/components/light/enocean.py b/homeassistant/components/light/enocean.py index ad4bc381b80..beb9094b1cb 100644 --- a/homeassistant/components/light/enocean.py +++ b/homeassistant/components/light/enocean.py @@ -20,14 +20,13 @@ _LOGGER = logging.getLogger(__name__) CONF_SENDER_ID = 'sender_id' DEFAULT_NAME = 'EnOcean Light' - DEPENDENCIES = ['enocean'] SUPPORT_ENOCEAN = SUPPORT_BRIGHTNESS PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_ID, default=[]): vol.All(cv.ensure_list, - [vol.Coerce(int)]), + vol.Optional(CONF_ID, default=[]): + vol.All(cv.ensure_list, [vol.Coerce(int)]), vol.Required(CONF_SENDER_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 4b9bed10201..499ec8f74ab 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -35,26 +35,26 @@ SUPPORT_FLUX_LED_RGBW = (SUPPORT_WHITE_VALUE | SUPPORT_EFFECT | MODE_RGB = 'rgb' MODE_RGBW = 'rgbw' -# List of Supported Effects which aren't already declared in LIGHT -EFFECT_RED_FADE = "red_fade" -EFFECT_GREEN_FADE = "green_fade" -EFFECT_BLUE_FADE = "blue_fade" -EFFECT_YELLOW_FADE = "yellow_fade" -EFFECT_CYAN_FADE = "cyan_fade" -EFFECT_PURPLE_FADE = "purple_fade" -EFFECT_WHITE_FADE = "white_fade" -EFFECT_RED_GREEN_CROSS_FADE = "rg_cross_fade" -EFFECT_RED_BLUE_CROSS_FADE = "rb_cross_fade" -EFFECT_GREEN_BLUE_CROSS_FADE = "gb_cross_fade" -EFFECT_COLORSTROBE = "colorstrobe" -EFFECT_RED_STROBE = "red_strobe" -EFFECT_GREEN_STROBE = "green_strobe" -EFFECT_BLUE_STOBE = "blue_strobe" -EFFECT_YELLOW_STROBE = "yellow_strobe" -EFFECT_CYAN_STROBE = "cyan_strobe" -EFFECT_PURPLE_STROBE = "purple_strobe" -EFFECT_WHITE_STROBE = "white_strobe" -EFFECT_COLORJUMP = "colorjump" +# List of supported effects which aren't already declared in LIGHT +EFFECT_RED_FADE = 'red_fade' +EFFECT_GREEN_FADE = 'green_fade' +EFFECT_BLUE_FADE = 'blue_fade' +EFFECT_YELLOW_FADE = 'yellow_fade' +EFFECT_CYAN_FADE = 'cyan_fade' +EFFECT_PURPLE_FADE = 'purple_fade' +EFFECT_WHITE_FADE = 'white_fade' +EFFECT_RED_GREEN_CROSS_FADE = 'rg_cross_fade' +EFFECT_RED_BLUE_CROSS_FADE = 'rb_cross_fade' +EFFECT_GREEN_BLUE_CROSS_FADE = 'gb_cross_fade' +EFFECT_COLORSTROBE = 'colorstrobe' +EFFECT_RED_STROBE = 'red_strobe' +EFFECT_GREEN_STROBE = 'green_strobe' +EFFECT_BLUE_STOBE = 'blue_strobe' +EFFECT_YELLOW_STROBE = 'yellow_strobe' +EFFECT_CYAN_STROBE = 'cyan_strobe' +EFFECT_PURPLE_STROBE = 'purple_strobe' +EFFECT_WHITE_STROBE = 'white_strobe' +EFFECT_COLORJUMP = 'colorjump' FLUX_EFFECT_LIST = [ EFFECT_COLORLOOP, @@ -121,7 +121,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ipaddr = device['ipaddr'] if ipaddr in light_ips: continue - device['name'] = device['id'] + " " + ipaddr + device['name'] = '{} {}'.format(device['id'], ipaddr) device[ATTR_MODE] = 'rgbw' device[CONF_PROTOCOL] = None light = FluxLight(device) @@ -167,7 +167,7 @@ class FluxLight(Light): @property def unique_id(self): """Return the ID of this light.""" - return "{}.{}".format(self.__class__, self._ipaddr) + return '{}.{}'.format(self.__class__, self._ipaddr) @property def name(self): diff --git a/homeassistant/components/light/insteon_local.py b/homeassistant/components/light/insteon_local.py index f7beb0c31ac..e5b99ca1cb2 100644 --- a/homeassistant/components/light/insteon_local.py +++ b/homeassistant/components/light/insteon_local.py @@ -84,7 +84,7 @@ def setup_light(device_id, name, insteonhub, hass, add_devices_callback): request_id = _CONFIGURING.pop(device_id) configurator = get_component('configurator') configurator.request_done(request_id) - _LOGGER.info("Device configuration done!") + _LOGGER.debug("Device configuration done") conf_lights = config_from_file(hass.config.path(INSTEON_LOCAL_LIGHTS_CONF)) if device_id not in conf_lights: @@ -107,7 +107,7 @@ def config_from_file(filename, config=None): with open(filename, 'w') as fdesc: fdesc.write(json.dumps(config)) except IOError as error: - _LOGGER.error('Saving config file failed: %s', error) + _LOGGER.error("Saving config file failed: %s", error) return False return True else: diff --git a/homeassistant/components/light/insteon_plm.py b/homeassistant/components/light/insteon_plm.py index 2cd22cf6d4d..3b3dd43f496 100644 --- a/homeassistant/components/light/insteon_plm.py +++ b/homeassistant/components/light/insteon_plm.py @@ -1,5 +1,5 @@ """ -Support for INSTEON lights via PowerLinc Modem. +Support for Insteon lights via PowerLinc Modem. For more details about this component, please refer to the documentation at https://home-assistant.io/components/insteon_plm/ @@ -12,16 +12,16 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) from homeassistant.loader import get_component +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['insteon_plm'] MAX_BRIGHTNESS = 255 -_LOGGER = logging.getLogger(__name__) - @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the INSTEON PLM device class for the hass platform.""" + """Set up the Insteon PLM device.""" plm = hass.data['insteon_plm'] device_list = [] @@ -30,7 +30,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): address = device.get('address_hex') dimmable = bool('dimmable' in device.get('capabilities')) - _LOGGER.info('Registered %s with light platform.', name) + _LOGGER.info("Registered %s with light platform", name) device_list.append( InsteonPLMDimmerDevice(hass, plm, address, name, dimmable) @@ -72,14 +72,14 @@ class InsteonPLMDimmerDevice(Light): def brightness(self): """Return the brightness of this light between 0..255.""" onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug('on level for %s is %s', self._address, onlevel) + _LOGGER.debug("on level for %s is %s", self._address, onlevel) return int(onlevel) @property def is_on(self): """Return the boolean response if the node is on.""" onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug('on level for %s is %s', self._address, onlevel) + _LOGGER.debug("on level for %s is %s", self._address, onlevel) return bool(onlevel) @property @@ -101,7 +101,7 @@ class InsteonPLMDimmerDevice(Light): @callback def async_light_update(self, message): """Receive notification from transport that new data exists.""" - _LOGGER.info('Received update calback from PLM for %s', self._address) + _LOGGER.info("Received update calback from PLM for %s", self._address) self._hass.async_add_job(self.async_update_ha_state()) @asyncio.coroutine diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index 1cde50de820..10feecca518 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -24,13 +24,12 @@ def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 light platform.""" if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error('A connection has not been made to the ISY controller.') + _LOGGER.error("A connection has not been made to the ISY controller") return False devices = [] - for node in isy.filter_nodes(isy.NODES, units=UOM, - states=STATES): + for node in isy.filter_nodes(isy.NODES, units=UOM, states=STATES): if node.dimmable or '51' in node.uom: devices.append(ISYLightDevice(node)) @@ -57,12 +56,12 @@ class ISYLightDevice(isy.ISYDevice, Light): def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 light device.""" if not self._node.off(): - _LOGGER.debug('Unable to turn on light.') + _LOGGER.debug("Unable to turn on light") def turn_on(self, brightness=None, **kwargs) -> None: """Send the turn on command to the ISY994 light device.""" if not self._node.on(val=brightness): - _LOGGER.debug('Unable to turn on light.') + _LOGGER.debug("Unable to turn on light") @property def supported_features(self): diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index bb2fd5a60f3..c3bf8d807da 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -4,7 +4,6 @@ Support for LimitlessLED bulbs. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.limitlessled/ """ - import logging import voluptuous as vol diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index 016771a15ca..fdd02d73349 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -1,4 +1,9 @@ -"""Support for the IKEA Tradfri platform.""" +""" +Support for the IKEA Tradfri platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.tradfri/ +""" import logging from homeassistant.components.light import ( From 8c90fd19ff77afcf55829075f5020dcc3693e798 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 10 May 2017 05:44:52 -0700 Subject: [PATCH 066/135] Try to request current_location Automatic scope (#7447) --- .../components/device_tracker/automatic.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 56dccd75d6d..b7c27a9d85f 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -31,7 +31,8 @@ CONF_DEVICES = 'devices' DEFAULT_TIMEOUT = 5 -SCOPE = ['location', 'vehicle:profile', 'trip'] +DEFAULT_SCOPE = ['location', 'vehicle:profile', 'trip'] +FULL_SCOPE = DEFAULT_SCOPE + ['current_location'] ATTR_FUEL_LEVEL = 'fuel_level' @@ -58,8 +59,17 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): client_session=async_get_clientsession(hass), request_kwargs={'timeout': DEFAULT_TIMEOUT}) try: - session = yield from client.create_session_from_password( - SCOPE, config[CONF_USERNAME], config[CONF_PASSWORD]) + try: + session = yield from client.create_session_from_password( + FULL_SCOPE, config[CONF_USERNAME], config[CONF_PASSWORD]) + except aioautomatic.exceptions.ForbiddenError as exc: + if not str(exc).startswith("invalid_scope"): + raise exc + _LOGGER.info("Client not authorized for current_location scope. " + "location:updated events will not be received.") + session = yield from client.create_session_from_password( + DEFAULT_SCOPE, config[CONF_USERNAME], config[CONF_PASSWORD]) + data = AutomaticData( hass, client, session, config[CONF_DEVICES], async_see) From 3bdf77ad6249c5ce3624cdd286e7a3446be9e8e9 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 10 May 2017 16:58:03 +0200 Subject: [PATCH 067/135] Add myStrom binary sensor (#7530) --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index f54cc6fbafe..297c5337869 100644 --- a/.coveragerc +++ b/.coveragerc @@ -180,6 +180,7 @@ omit = homeassistant/components/binary_sensor/flic.py homeassistant/components/binary_sensor/hikvision.py homeassistant/components/binary_sensor/iss.py + homeassistant/components/binary_sensor/mystrom.py homeassistant/components/binary_sensor/pilight.py homeassistant/components/binary_sensor/ping.py homeassistant/components/binary_sensor/rest.py From 8cdadd2aa098cf4253602df800e73b8e9d19a8fc Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 11 May 2017 09:14:52 +0200 Subject: [PATCH 068/135] Add not-context-manager (#7523) * Add not-context-manager * Add missing comma --- pylintrc | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/pylintrc b/pylintrc index 4c0b1523078..e94cbffe9f9 100644 --- a/pylintrc +++ b/pylintrc @@ -15,24 +15,25 @@ reports=no # abstract-method - with intro of async there are always methods missing disable= - locally-disabled, - duplicate-code, - cyclic-import, abstract-class-little-used, abstract-class-not-used, - unused-argument, + abstract-method, + cyclic-import, + duplicate-code, global-statement, + locally-disabled, + not-context-manager, redefined-variable-type, + too-few-public-methods, too-many-arguments, too-many-branches, too-many-instance-attributes, + too-many-lines, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, - too-many-lines, - too-few-public-methods, - abstract-method + unused-argument [EXCEPTIONS] overgeneral-exceptions=Exception,HomeAssistantError From 2c8f6a0ad0077aeef68f70e5e6e7c176df95dda3 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Thu, 11 May 2017 09:24:36 +0200 Subject: [PATCH 069/135] Threadsafe configurator (#7536) * Make Configurator thread safe, get_instance timing issues breaking configurator working on multiple devices * No blank lines allowed after function docstring * Fix comment Tox --- homeassistant/components/configurator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index c37f07956e4..e502e0a0253 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -9,9 +9,11 @@ the user has submitted configuration information. import asyncio import logging +from homeassistant.core import callback as async_callback from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME, \ ATTR_ENTITY_PICTURE from homeassistant.helpers.entity import generate_entity_id +from homeassistant.util.async import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) _REQUESTS = {} @@ -43,7 +45,9 @@ def request_config( Will return an ID to be used for sequent calls. """ - instance = _get_instance(hass) + instance = run_callback_threadsafe(hass.loop, + _async_get_instance, + hass).result() request_id = instance.request_config( name, callback, @@ -79,7 +83,8 @@ def async_setup(hass, config): return True -def _get_instance(hass): +@async_callback +def _async_get_instance(hass): """Get an instance per hass object.""" instance = hass.data.get(_KEY_INSTANCE) @@ -97,7 +102,7 @@ class Configurator(object): self.hass = hass self._cur_id = 0 self._requests = {} - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_CONFIGURE, self.handle_service_call) def request_config( From ef4587f994f0e902528c0a756d450b5903a70238 Mon Sep 17 00:00:00 2001 From: jumpkick Date: Thu, 11 May 2017 12:04:17 -0400 Subject: [PATCH 070/135] Fix for #7459 (#7544) * Generate a new updateDate with every call This should fix #7459 Tests need to be updated in another commit. * Replace STATIC_TIME with datetime object check Removing the "DATE" argument from the Alexa component's configuration (because it is now dynamically generated) requires this commit's changes to the test cases to check that the updateDate data is a datetime type rather than a specific hardcoded value ('2016-10-10T19:51:42.0Z'). * Fix brackets --- homeassistant/components/alexa.py | 8 +------- tests/components/test_alexa.py | 26 ++++++++------------------ 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py index 59d2ec8852a..1a3708fb746 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa.py @@ -17,7 +17,6 @@ from homeassistant.core import callback from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import template, script, config_validation as cv from homeassistant.components.http import HomeAssistantView -import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -36,7 +35,6 @@ CONF_TEXT = 'text' CONF_FLASH_BRIEFINGS = 'flash_briefings' CONF_UID = 'uid' -CONF_DATE = 'date' CONF_TITLE = 'title' CONF_AUDIO = 'audio' CONF_TEXT = 'text' @@ -88,7 +86,6 @@ CONFIG_SCHEMA = vol.Schema({ CONF_FLASH_BRIEFINGS: { cv.string: vol.All(cv.ensure_list, [{ vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, - vol.Optional(CONF_DATE, default=datetime.utcnow()): cv.string, vol.Required(CONF_TITLE): cv.template, vol.Optional(CONF_AUDIO): cv.template, vol.Required(CONF_TEXT, default=""): cv.template, @@ -331,10 +328,7 @@ class AlexaFlashBriefingView(HomeAssistantView): else: output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) - if isinstance(item[CONF_DATE], str): - item[CONF_DATE] = dt_util.parse_datetime(item[CONF_DATE]) - - output[ATTR_UPDATE_DATE] = item[CONF_DATE].strftime(DATE_FORMAT) + output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) briefing.append(output) diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py index 66d506d40c9..47a3e086d29 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/test_alexa.py @@ -19,9 +19,6 @@ calls = [] NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" -# 2016-10-10T19:51:42+00:00 -STATIC_TIME = datetime.datetime.utcfromtimestamp(1476129102) - @pytest.fixture def alexa_client(loop, hass, test_client): @@ -39,17 +36,14 @@ def alexa_client(loop, hass, test_client): "flash_briefings": { "weather": [ {"title": "Weekly forecast", - "text": "This week it will be sunny.", - "date": "2016-10-09T19:51:42.0Z"}, + "text": "This week it will be sunny."}, {"title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit.", - "date": STATIC_TIME} + "text": "Currently it is 80 degrees fahrenheit."} ], "news_audio": { "title": "NPR", "audio": NPR_NEWS_MP3_URL, "display_url": "https://npr.org", - "date": STATIC_TIME, "uid": "uuid" } }, @@ -436,16 +430,8 @@ def test_flash_briefing_date_from_str(alexa_client): req = yield from _flash_briefing_req(alexa_client, "weather") assert req.status == 200 data = yield from req.json() - assert data[0].get(alexa.ATTR_UPDATE_DATE) == "2016-10-09T19:51:42.0Z" - - -@asyncio.coroutine -def test_flash_briefing_date_from_datetime(alexa_client): - """Test the response has a valid date from a datetime object.""" - req = yield from _flash_briefing_req(alexa_client, "weather") - assert req.status == 200 - data = yield from req.json() - assert data[1].get(alexa.ATTR_UPDATE_DATE) == '2016-10-10T19:51:42.0Z' + assert isinstance(datetime.datetime.strptime(data[0].get( + alexa.ATTR_UPDATE_DATE), alexa.DATE_FORMAT), datetime.datetime) @asyncio.coroutine @@ -463,4 +449,8 @@ def test_flash_briefing_valid(alexa_client): req = yield from _flash_briefing_req(alexa_client, "news_audio") assert req.status == 200 json = yield from req.json() + assert isinstance(datetime.datetime.strptime(json[0].get( + alexa.ATTR_UPDATE_DATE), alexa.DATE_FORMAT), datetime.datetime) + json[0].pop(alexa.ATTR_UPDATE_DATE) + data[0].pop(alexa.ATTR_UPDATE_DATE) assert json == data From 966bda079e58ff5b1a31756ba501f61c203fbdc6 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 11 May 2017 18:06:22 +0200 Subject: [PATCH 071/135] Upgrade sendgrid to 4.1.0 (#7538) --- homeassistant/components/notify/sendgrid.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index bb4a5078013..d3ba79a059f 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==4.0.0'] +REQUIREMENTS = ['sendgrid==4.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 4d352c9c22b..145323f36dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -753,7 +753,7 @@ schiene==0.18 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==4.0.0 +sendgrid==4.1.0 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat From 04f1054d070b11efb8e1f2149332e61dd86e7879 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Thu, 11 May 2017 13:47:47 -0700 Subject: [PATCH 072/135] Automatic version bump (#7555) --- homeassistant/components/device_tracker/automatic.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index b7c27a9d85f..891f1b22775 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -21,7 +21,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.3.1'] +REQUIREMENTS = ['aioautomatic==0.4.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 145323f36dd..0351eea9b56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -39,7 +39,7 @@ SoCo==0.12 TwitterAPI==2.4.5 # homeassistant.components.device_tracker.automatic -aioautomatic==0.3.1 +aioautomatic==0.4.0 # homeassistant.components.sensor.dnsip aiodns==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0253f41f734..238de072c12 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -28,7 +28,7 @@ PyJWT==1.4.2 SoCo==0.12 # homeassistant.components.device_tracker.automatic -aioautomatic==0.3.1 +aioautomatic==0.4.0 # homeassistant.components.emulated_hue # homeassistant.components.http From 0e41342a404a6129229333e071cb642be7c6eeeb Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 11 May 2017 22:48:03 +0200 Subject: [PATCH 073/135] Upgrade dweepy to 0.3.0 (#7550) --- homeassistant/components/dweet.py | 4 ++-- homeassistant/components/sensor/dweet.py | 26 +++++++++++++----------- requirements_all.txt | 2 +- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/dweet.py b/homeassistant/components/dweet.py index b4e8d68e960..d5f94bb2c7b 100644 --- a/homeassistant/components/dweet.py +++ b/homeassistant/components/dweet.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import state as state_helper from homeassistant.util import Throttle -REQUIREMENTS = ['dweepy==0.2.0'] +REQUIREMENTS = ['dweepy==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -67,4 +67,4 @@ def send_data(name, msg): try: dweepy.dweet_for(name, msg) except dweepy.DweepyError: - _LOGGER.error("Error saving data '%s' to Dweet.io", msg) + _LOGGER.error("Error saving data to Dweet.io: %s", msg) diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py index e5f3d00830b..c049368153c 100644 --- a/homeassistant/components/sensor/dweet.py +++ b/homeassistant/components/sensor/dweet.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['dweepy==0.2.0'] +REQUIREMENTS = ['dweepy==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -44,7 +44,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device = config.get(CONF_DEVICE) value_template = config.get(CONF_VALUE_TEMPLATE) unit = config.get(CONF_UNIT_OF_MEASUREMENT) - value_template.hass = hass + if value_template is not None: + value_template.hass = hass + try: content = json.dumps(dweepy.get_latest_dweet_for(device)[0]['content']) except dweepy.DweepyError: @@ -57,7 +59,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dweet = DweetData(device) - add_devices([DweetSensor(hass, dweet, name, value_template, unit)]) + add_devices([DweetSensor(hass, dweet, name, value_template, unit)], True) class DweetSensor(Entity): @@ -71,7 +73,6 @@ class DweetSensor(Entity): self._value_template = value_template self._state = STATE_UNKNOWN self._unit_of_measurement = unit_of_measurement - self.update() @property def name(self): @@ -86,18 +87,19 @@ class DweetSensor(Entity): @property def state(self): """Return the state.""" - if self.dweet.data is None: - return STATE_UNKNOWN - else: - values = json.dumps(self.dweet.data[0]['content']) - value = self._value_template.render_with_possible_json_value( - values) - return value + return self._state def update(self): """Get the latest data from REST API.""" self.dweet.update() + if self.dweet.data is None: + self._state = STATE_UNKNOWN + else: + values = json.dumps(self.dweet.data[0]['content']) + self._state = self._value_template.render_with_possible_json_value( + values, STATE_UNKNOWN) + class DweetData(object): """The class for handling the data retrieval.""" @@ -115,5 +117,5 @@ class DweetData(object): try: self.data = dweepy.get_latest_dweet_for(self._device) except dweepy.DweepyError: - _LOGGER.error("Device %s could not be found", self._device) + _LOGGER.warning("Device %s doesn't contain any data", self._device) self.data = None diff --git a/requirements_all.txt b/requirements_all.txt index 0351eea9b56..21bc4ef072b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,7 +160,7 @@ dsmr_parser==0.8 # homeassistant.components.dweet # homeassistant.components.sensor.dweet -dweepy==0.2.0 +dweepy==0.3.0 # homeassistant.components.sensor.eliqonline eliqonline==1.0.13 From 0e246059f92db1726c14106f7822a45d72879e97 Mon Sep 17 00:00:00 2001 From: Trevor Date: Thu, 11 May 2017 16:05:06 -0500 Subject: [PATCH 074/135] Add SSL support to NZBGet sensor (#7553) --- homeassistant/components/sensor/nzbget.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/nzbget.py b/homeassistant/components/sensor/nzbget.py index 7a95e445ae0..a440074b81b 100644 --- a/homeassistant/components/sensor/nzbget.py +++ b/homeassistant/components/sensor/nzbget.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, - CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) + CONF_SSL, CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -44,6 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_USERNAME): cv.string, }) @@ -53,12 +54,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the NZBGet sensors.""" host = config.get(CONF_HOST) port = config.get(CONF_PORT) + ssl = 's' if config.get(CONF_SSL) else '' name = config.get(CONF_NAME) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) monitored_types = config.get(CONF_MONITORED_VARIABLES) - url = "http://{}:{}/jsonrpc".format(host, port) + url = "http{}://{}:{}/jsonrpc".format(ssl, host, port) try: nzbgetapi = NZBGetAPI( From 76675a54f820e85101bd60e47dc6774536ad62c2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 11 May 2017 19:20:23 -0700 Subject: [PATCH 075/135] Do not install all dependencies in dev mode (#7548) * ps - do not install all dependencies * Comment out blinkt because it depends on GPIO * Add pip upgrade check back * Disable import error blinkt * Update comment * Fix comment --- homeassistant/components/light/blinkt.py | 1 + requirements_all.txt | 2 +- script/bootstrap_server | 14 ++++---------- script/gen_requirements_all.py | 3 ++- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/light/blinkt.py b/homeassistant/components/light/blinkt.py index ffd3c102c7f..e2bef31089f 100644 --- a/homeassistant/components/light/blinkt.py +++ b/homeassistant/components/light/blinkt.py @@ -29,6 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Blinkt Light platform.""" + # pylint: disable=import-error import blinkt # ensure that the lights are off when exiting diff --git a/requirements_all.txt b/requirements_all.txt index 21bc4ef072b..308b8f6545d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -93,7 +93,7 @@ blinkpy==0.5.2 blinkstick==1.1.8 # homeassistant.components.light.blinkt -blinkt==0.1.0 +# blinkt==0.1.0 # homeassistant.components.sensor.bitcoin blockchain==1.3.3 diff --git a/script/bootstrap_server b/script/bootstrap_server index 38684f9266c..633a3498b52 100755 --- a/script/bootstrap_server +++ b/script/bootstrap_server @@ -6,21 +6,15 @@ set -e cd "$(dirname "$0")/.." -echo "Installing dependencies..." -# Requirements_all.txt states minimum pip version as 7.0.0 however, -# parameter --only-binary doesn't work with pip < 7.0.0. Causing -# python3 -m pip install -r requirements_all.txt to fail unless pip upgraded. - +# Some requirements use parameter --only-binary only available +# in pip 7+. Upgrade if necessary. if ! python3 -c 'import pkg_resources ; pkg_resources.require(["pip>=7.0.0"])' 2>/dev/null ; then echo "Upgrading pip..." python3 -m pip install -U pip fi -python3 -m pip install -r requirements_all.txt -REQ_STATUS=$? - -echo "Installing development dependencies..." -python3 -m pip install -r requirements_test.txt +echo "Installing test dependencies..." +python3 -m pip install -r requirements_test_all.txt REQ_DEV_STATUS=$? diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fd63436dfd0..64091c5c2cc 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -25,7 +25,8 @@ COMMENT_REQUIREMENTS = ( 'python-eq3bt', 'avion', 'decora', - 'face_recognition' + 'face_recognition', + 'blinkt', ) TEST_REQUIREMENTS = ( From b805d8a844974123a4ba871215b1e5ae37d332c1 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Thu, 11 May 2017 19:37:32 -0700 Subject: [PATCH 076/135] Hide proximity updates in logbook (#7549) --- homeassistant/components/logbook.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index bdc3fa3dce3..053648e3428 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -50,6 +50,8 @@ CONFIG_SCHEMA = vol.Schema({ GROUP_BY_MINUTES = 15 +CONTINUOUS_DOMAINS = ['proximity', 'sensor'] + ATTR_NAME = 'name' ATTR_MESSAGE = 'message' ATTR_DOMAIN = 'domain' @@ -190,7 +192,8 @@ def humanify(events): if entity_id is None: continue - if entity_id.startswith('sensor.'): + if entity_id.startswith(tuple('{}.'.format( + domain) for domain in CONTINUOUS_DOMAINS)): last_sensor_event[entity_id] = event elif event.event_type == EVENT_HOMEASSISTANT_STOP: @@ -222,12 +225,12 @@ def humanify(events): domain = to_state.domain # Skip all but the last sensor state - if domain == 'sensor' and \ + if domain in CONTINUOUS_DOMAINS and \ event != last_sensor_event[to_state.entity_id]: continue # Don't show continuous sensor value changes in the logbook - if domain == 'sensor' and \ + if domain in CONTINUOUS_DOMAINS and \ to_state.attributes.get('unit_of_measurement'): continue From 8da10f670b3043a1582413f90925dbef069cda4b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 12 May 2017 00:01:06 -0700 Subject: [PATCH 077/135] Only install tox in dev mode (#7557) --- script/bootstrap_frontend | 2 +- script/bootstrap_server | 20 ++------------------ 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/script/bootstrap_frontend b/script/bootstrap_frontend index e1d4ef887be..0efe2e3584d 100755 --- a/script/bootstrap_frontend +++ b/script/bootstrap_frontend @@ -1,5 +1,5 @@ #!/bin/sh -# Resolve all frontend dependencies that the application requires to run. +# Resolve all frontend dependencies that the application requires to develop. # Stop on errors set -e diff --git a/script/bootstrap_server b/script/bootstrap_server index 633a3498b52..7929a00fe55 100755 --- a/script/bootstrap_server +++ b/script/bootstrap_server @@ -1,26 +1,10 @@ #!/bin/sh -# Resolve all server dependencies that the application requires to run. +# Resolve all server dependencies that the application requires to develop. # Stop on errors set -e cd "$(dirname "$0")/.." -# Some requirements use parameter --only-binary only available -# in pip 7+. Upgrade if necessary. -if ! python3 -c 'import pkg_resources ; pkg_resources.require(["pip>=7.0.0"])' 2>/dev/null ; then - echo "Upgrading pip..." - python3 -m pip install -U pip -fi - echo "Installing test dependencies..." -python3 -m pip install -r requirements_test_all.txt - -REQ_DEV_STATUS=$? - -if [ $REQ_DEV_STATUS -eq 0 ] -then - exit $REQ_STATUS -else - exit $REQ_DEV_STATUS -fi +python3 -m pip install tox From 452c3a1b25c5f0dec2c844367b397174f69e4e1c Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Fri, 12 May 2017 11:53:25 +0300 Subject: [PATCH 078/135] Support adding different server locations for Microsoft face component (#7532) * Support adding different server locations * Rename variables and move CONF_ const into component as requested in review * Fix unittests * Forgot to add tests for microsoft_face_identify --- homeassistant/components/microsoft_face.py | 11 ++-- .../test_microsoft_face_detect.py | 10 ++-- .../test_microsoft_face_identify.py | 12 +++-- tests/components/test_microsoft_face.py | 54 ++++++++++--------- 4 files changed, 49 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py index a0ff2ed99e7..a2a52b68665 100644 --- a/homeassistant/components/microsoft_face.py +++ b/homeassistant/components/microsoft_face.py @@ -28,10 +28,12 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'microsoft_face' DEPENDENCIES = ['camera'] -FACE_API_URL = "https://westus.api.cognitive.microsoft.com/face/v1.0/{0}" +FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}" DATA_MICROSOFT_FACE = 'microsoft_face' +CONF_AZURE_REGION = 'azure_region' + SERVICE_CREATE_GROUP = 'create_group' SERVICE_DELETE_GROUP = 'delete_group' SERVICE_TRAIN_GROUP = 'train_group' @@ -49,6 +51,7 @@ DEFAULT_TIMEOUT = 10 CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_AZURE_REGION, default="westus"): cv.string, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }), }, extra=vol.ALLOW_EXTRA) @@ -115,6 +118,7 @@ def async_setup(hass, config): entities = {} face = MicrosoftFace( hass, + config[DOMAIN].get(CONF_AZURE_REGION), config[DOMAIN].get(CONF_API_KEY), config[DOMAIN].get(CONF_TIMEOUT), entities @@ -304,12 +308,13 @@ class MicrosoftFaceGroupEntity(Entity): class MicrosoftFace(object): """Microsoft Face api for HomeAssistant.""" - def __init__(self, hass, api_key, timeout, entities): + def __init__(self, hass, server_loc, api_key, timeout, entities): """Initialize Microsoft Face api.""" self.hass = hass self.websession = async_get_clientsession(hass) self.timeout = timeout self._api_key = api_key + self._server_url = "https://{0}.{1}".format(server_loc, FACE_API_URL) self._store = {} self._entities = entities @@ -346,7 +351,7 @@ class MicrosoftFace(object): params=None): """Make a api call.""" headers = {"Ocp-Apim-Subscription-Key": self._api_key} - url = FACE_API_URL.format(function) + url = self._server_url.format(function) payload = None if binary: diff --git a/tests/components/image_processing/test_microsoft_face_detect.py b/tests/components/image_processing/test_microsoft_face_detect.py index f398db991c2..b743dee9704 100644 --- a/tests/components/image_processing/test_microsoft_face_detect.py +++ b/tests/components/image_processing/test_microsoft_face_detect.py @@ -98,6 +98,8 @@ class TestMicrosoftFaceDetect(object): } } + self.endpoint_url = "https://westus.{0}".format(mf.FACE_API_URL) + def teardown_method(self): """Stop everything that was started.""" self.hass.stop() @@ -108,15 +110,15 @@ class TestMicrosoftFaceDetect(object): def test_ms_detect_process_image(self, poll_mock, aioclient_mock): """Setup and scan a picture and test plates from event.""" aioclient_mock.get( - mf.FACE_API_URL.format("persongroups"), + self.endpoint_url.format("persongroups"), text=load_fixture('microsoft_face_persongroups.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group1/persons"), + self.endpoint_url.format("persongroups/test_group1/persons"), text=load_fixture('microsoft_face_persons.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group2/persons"), + self.endpoint_url.format("persongroups/test_group2/persons"), text=load_fixture('microsoft_face_persons.json') ) @@ -139,7 +141,7 @@ class TestMicrosoftFaceDetect(object): aioclient_mock.get(url, content=b'image') aioclient_mock.post( - mf.FACE_API_URL.format("detect"), + self.endpoint_url.format("detect"), text=load_fixture('microsoft_face_detect.json'), params={'returnFaceAttributes': "age,gender"} ) diff --git a/tests/components/image_processing/test_microsoft_face_identify.py b/tests/components/image_processing/test_microsoft_face_identify.py index a7958b68de7..c2ab5684ed0 100644 --- a/tests/components/image_processing/test_microsoft_face_identify.py +++ b/tests/components/image_processing/test_microsoft_face_identify.py @@ -99,6 +99,8 @@ class TestMicrosoftFaceIdentify(object): } } + self.endpoint_url = "https://westus.{0}".format(mf.FACE_API_URL) + def teardown_method(self): """Stop everything that was started.""" self.hass.stop() @@ -109,15 +111,15 @@ class TestMicrosoftFaceIdentify(object): def test_ms_identify_process_image(self, poll_mock, aioclient_mock): """Setup and scan a picture and test plates from event.""" aioclient_mock.get( - mf.FACE_API_URL.format("persongroups"), + self.endpoint_url.format("persongroups"), text=load_fixture('microsoft_face_persongroups.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group1/persons"), + self.endpoint_url.format("persongroups/test_group1/persons"), text=load_fixture('microsoft_face_persons.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group2/persons"), + self.endpoint_url.format("persongroups/test_group2/persons"), text=load_fixture('microsoft_face_persons.json') ) @@ -140,11 +142,11 @@ class TestMicrosoftFaceIdentify(object): aioclient_mock.get(url, content=b'image') aioclient_mock.post( - mf.FACE_API_URL.format("detect"), + self.endpoint_url.format("detect"), text=load_fixture('microsoft_face_detect.json') ) aioclient_mock.post( - mf.FACE_API_URL.format("identify"), + self.endpoint_url.format("identify"), text=load_fixture('microsoft_face_identify.json') ) diff --git a/tests/components/test_microsoft_face.py b/tests/components/test_microsoft_face.py index bb95c7e51c1..7a047a73f47 100644 --- a/tests/components/test_microsoft_face.py +++ b/tests/components/test_microsoft_face.py @@ -22,6 +22,8 @@ class TestMicrosoftFaceSetup(object): } } + self.endpoint_url = "https://westus.{0}".format(mf.FACE_API_URL) + def teardown_method(self): """Stop everything that was started.""" self.hass.stop() @@ -30,7 +32,7 @@ class TestMicrosoftFaceSetup(object): 'MicrosoftFace.update_store', return_value=mock_coro()) def test_setup_component(self, mock_update): """Setup component.""" - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) @patch('homeassistant.components.microsoft_face.' @@ -44,7 +46,7 @@ class TestMicrosoftFaceSetup(object): 'MicrosoftFace.update_store', return_value=mock_coro()) def test_setup_component_test_service(self, mock_update): """Setup component.""" - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) assert self.hass.services.has_service(mf.DOMAIN, 'create_group') @@ -57,19 +59,19 @@ class TestMicrosoftFaceSetup(object): def test_setup_component_test_entities(self, aioclient_mock): """Setup component.""" aioclient_mock.get( - mf.FACE_API_URL.format("persongroups"), + self.endpoint_url.format("persongroups"), text=load_fixture('microsoft_face_persongroups.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group1/persons"), + self.endpoint_url.format("persongroups/test_group1/persons"), text=load_fixture('microsoft_face_persons.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group2/persons"), + self.endpoint_url.format("persongroups/test_group2/persons"), text=load_fixture('microsoft_face_persons.json') ) - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) assert len(aioclient_mock.mock_calls) == 3 @@ -95,15 +97,15 @@ class TestMicrosoftFaceSetup(object): def test_service_groups(self, mock_update, aioclient_mock): """Setup component, test groups services.""" aioclient_mock.put( - mf.FACE_API_URL.format("persongroups/service_group"), + self.endpoint_url.format("persongroups/service_group"), status=200, text="{}" ) aioclient_mock.delete( - mf.FACE_API_URL.format("persongroups/service_group"), + self.endpoint_url.format("persongroups/service_group"), status=200, text="{}" ) - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) mf.create_group(self.hass, 'Service Group') @@ -123,29 +125,29 @@ class TestMicrosoftFaceSetup(object): def test_service_person(self, aioclient_mock): """Setup component, test person services.""" aioclient_mock.get( - mf.FACE_API_URL.format("persongroups"), + self.endpoint_url.format("persongroups"), text=load_fixture('microsoft_face_persongroups.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group1/persons"), + self.endpoint_url.format("persongroups/test_group1/persons"), text=load_fixture('microsoft_face_persons.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group2/persons"), + self.endpoint_url.format("persongroups/test_group2/persons"), text=load_fixture('microsoft_face_persons.json') ) - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) assert len(aioclient_mock.mock_calls) == 3 aioclient_mock.post( - mf.FACE_API_URL.format("persongroups/test_group1/persons"), + self.endpoint_url.format("persongroups/test_group1/persons"), text=load_fixture('microsoft_face_create_person.json') ) aioclient_mock.delete( - mf.FACE_API_URL.format( + self.endpoint_url.format( "persongroups/test_group1/persons/" "25985303-c537-4467-b41d-bdb45cd95ca1"), status=200, text="{}" @@ -174,11 +176,11 @@ class TestMicrosoftFaceSetup(object): 'MicrosoftFace.update_store', return_value=mock_coro()) def test_service_train(self, mock_update, aioclient_mock): """Setup component, test train groups services.""" - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) aioclient_mock.post( - mf.FACE_API_URL.format("persongroups/service_group/train"), + self.endpoint_url.format("persongroups/service_group/train"), status=200, text="{}" ) @@ -192,26 +194,26 @@ class TestMicrosoftFaceSetup(object): def test_service_face(self, camera_mock, aioclient_mock): """Setup component, test person face services.""" aioclient_mock.get( - mf.FACE_API_URL.format("persongroups"), + self.endpoint_url.format("persongroups"), text=load_fixture('microsoft_face_persongroups.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group1/persons"), + self.endpoint_url.format("persongroups/test_group1/persons"), text=load_fixture('microsoft_face_persons.json') ) aioclient_mock.get( - mf.FACE_API_URL.format("persongroups/test_group2/persons"), + self.endpoint_url.format("persongroups/test_group2/persons"), text=load_fixture('microsoft_face_persons.json') ) self.config['camera'] = {'platform': 'demo'} - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) assert len(aioclient_mock.mock_calls) == 3 aioclient_mock.post( - mf.FACE_API_URL.format( + self.endpoint_url.format( "persongroups/test_group2/persons/" "2ae4935b-9659-44c3-977f-61fac20d0538/persistedFaces"), status=200, text="{}" @@ -229,11 +231,11 @@ class TestMicrosoftFaceSetup(object): def test_service_status_400(self, mock_update, aioclient_mock): """Setup component, test groups services with error.""" aioclient_mock.put( - mf.FACE_API_URL.format("persongroups/service_group"), + self.endpoint_url.format("persongroups/service_group"), status=400, text="{'error': {'message': 'Error'}}" ) - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) mf.create_group(self.hass, 'Service Group') @@ -248,11 +250,11 @@ class TestMicrosoftFaceSetup(object): def test_service_status_timeout(self, mock_update, aioclient_mock): """Setup component, test groups services with timeout.""" aioclient_mock.put( - mf.FACE_API_URL.format("persongroups/service_group"), + self.endpoint_url.format("persongroups/service_group"), status=400, exc=asyncio.TimeoutError() ) - with assert_setup_component(2, mf.DOMAIN): + with assert_setup_component(3, mf.DOMAIN): setup_component(self.hass, mf.DOMAIN, self.config) mf.create_group(self.hass, 'Service Group') From 5b3ef0f76f368d2d33672d7d184506fdd598ee0b Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 12 May 2017 18:28:58 +0300 Subject: [PATCH 079/135] Treat swing and fan level as optional in Sensibo Climate. (#7560) --- homeassistant/components/climate/sensibo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index afb04fa3c91..79d231a69c5 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -149,22 +149,22 @@ class SensiboClimate(ClimateDevice): @property def current_fan_mode(self): """Return the fan setting.""" - return self._ac_states['fanLevel'] + return self._ac_states.get('fanLevel') @property def fan_list(self): """List of available fan modes.""" - return self._current_capabilities['fanLevels'] + return self._current_capabilities.get('fanLevels') @property def current_swing_mode(self): """Return the fan setting.""" - return self._ac_states['swing'] + return self._ac_states.get('swing') @property def swing_list(self): """List of available swing modes.""" - return self._current_capabilities['swing'] + return self._current_capabilities.get('swing') @property def name(self): From 416b8e0efe15566f476c9a516b5f15ff3ef09066 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Fri, 12 May 2017 17:51:54 +0200 Subject: [PATCH 080/135] Axis component (#7381) * Added Axis hub, binary sensors and camera * Added Axis logo to static images * Added Axis logo to configurator Added Axis mdns discovery * Fixed flake8 and pylint comments * Missed a change from list to function call V5 of axis py * Added dependencies to requirements_all.txt * Clean up * Added files to coveragerc * Guide lines says to import function when needed, this makes Tox pass * Removed storing hass in config until at the end where I send it to axisdevice * Don't call update in the constructor * Don't keep hass private * Unnecessary lint ignore, following Baloobs suggestion of using NotImplementedError * Axis package not in pypi yet * Do not catch bare excepts. Device schema validations raise vol.Invalid. * setup_device still adds hass object to the config, so the need to remove it prior to writing config file still remains * Don't expect axis.conf contains correct values * Improved configuration validation * Trigger time better explains functionality than scan interval * Forgot to remove this earlier * Guideline says double qoutes for sentences * Return false from discovery if config file contains bad data * Keys in AXIS_DEVICES are serialnumber * Ordered imports in alphabetical order * Moved requirement to pypi * Moved update callback that handles trigger time to axis binary sensor * Renamed configurator instance to request_id since that is what it really is * Removed unnecessary configurator steps * Changed link in configurator to platform documentation * Add not-context-manager (#7523) * Add not-context-manager * Add missing comma * Threadsafe configurator (#7536) * Make Configurator thread safe, get_instance timing issues breaking configurator working on multiple devices * No blank lines allowed after function docstring * Fix comment Tox * Added Axis hub, binary sensors and camera * Added Axis logo to static images * Added Axis logo to configurator Added Axis mdns discovery * Fixed flake8 and pylint comments * Missed a change from list to function call V5 of axis py * Added dependencies to requirements_all.txt * Clean up * Added files to coveragerc * Guide lines says to import function when needed, this makes Tox pass * Removed storing hass in config until at the end where I send it to axisdevice * Don't call update in the constructor * Don't keep hass private * Unnecessary lint ignore, following Baloobs suggestion of using NotImplementedError * Axis package not in pypi yet * Do not catch bare excepts. Device schema validations raise vol.Invalid. * setup_device still adds hass object to the config, so the need to remove it prior to writing config file still remains * Don't expect axis.conf contains correct values * Improved configuration validation * Trigger time better explains functionality than scan interval * Forgot to remove this earlier * Guideline says double qoutes for sentences * Return false from discovery if config file contains bad data * Keys in AXIS_DEVICES are serialnumber * Ordered imports in alphabetical order * Moved requirement to pypi * Moved update callback that handles trigger time to axis binary sensor * Renamed configurator instance to request_id since that is what it really is * Removed unnecessary configurator steps * Changed link in configurator to platform documentation * No blank lines allowed after function docstring * No blank lines allowed after function docstring * Changed discovery to use axis instead of axis_mdns * Travis CI requested rerun of script/gen_requirements_all.py --- .coveragerc | 3 + homeassistant/components/axis.py | 314 ++++++++++++++++++ .../components/binary_sensor/axis.py | 68 ++++ homeassistant/components/camera/axis.py | 38 +++ homeassistant/components/discovery.py | 2 + .../frontend/www_static/images/logo_axis.png | Bin 0 -> 2858 bytes requirements_all.txt | 3 + 7 files changed, 428 insertions(+) create mode 100644 homeassistant/components/axis.py create mode 100644 homeassistant/components/binary_sensor/axis.py create mode 100644 homeassistant/components/camera/axis.py create mode 100644 homeassistant/components/frontend/www_static/images/logo_axis.png diff --git a/.coveragerc b/.coveragerc index 297c5337869..60df26cf153 100644 --- a/.coveragerc +++ b/.coveragerc @@ -20,6 +20,9 @@ omit = homeassistant/components/android_ip_webcam.py homeassistant/components/*/android_ip_webcam.py + homeassistant/components/axis.py + homeassistant/components/*/axis.py + homeassistant/components/bbb_gpio.py homeassistant/components/*/bbb_gpio.py diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py new file mode 100644 index 00000000000..593eee2356e --- /dev/null +++ b/homeassistant/components/axis.py @@ -0,0 +1,314 @@ +""" +Support for Axis devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/axis/ +""" + +import json +import logging +import os + +import voluptuous as vol + +from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, + CONF_HOST, CONF_INCLUDE, CONF_NAME, + CONF_PASSWORD, CONF_TRIGGER_TIME, + CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) +from homeassistant.components.discovery import SERVICE_AXIS +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity +from homeassistant.loader import get_component + + +REQUIREMENTS = ['axis==7'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'axis' +CONFIG_FILE = 'axis.conf' + +AXIS_DEVICES = {} + +EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound', + 'daynight', 'tampering', 'input'] + +PLATFORMS = ['camera'] + +AXIS_INCLUDE = EVENT_TYPES + PLATFORMS + +AXIS_DEFAULT_HOST = '192.168.0.90' +AXIS_DEFAULT_USERNAME = 'root' +AXIS_DEFAULT_PASSWORD = 'pass' + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_INCLUDE): + vol.All(cv.ensure_list, [vol.In(AXIS_INCLUDE)]), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, + vol.Optional(ATTR_LOCATION, default=''): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: DEVICE_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + + +def request_configuration(hass, name, host, serialnumber): + """Request configuration steps from the user.""" + configurator = get_component('configurator') + + def configuration_callback(callback_data): + """Called when config is submitted.""" + if CONF_INCLUDE not in callback_data: + configurator.notify_errors(request_id, + "Functionality mandatory.") + return False + callback_data[CONF_INCLUDE] = callback_data[CONF_INCLUDE].split() + callback_data[CONF_HOST] = host + if CONF_NAME not in callback_data: + callback_data[CONF_NAME] = name + try: + config = DEVICE_SCHEMA(callback_data) + except vol.Invalid: + configurator.notify_errors(request_id, + "Bad input, please check spelling.") + return False + + if setup_device(hass, config): + config_file = _read_config(hass) + config_file[serialnumber] = dict(config) + del config_file[serialnumber]['hass'] + _write_config(hass, config_file) + configurator.request_done(request_id) + else: + configurator.notify_errors(request_id, + "Failed to register, please try again.") + return False + + title = '{} ({})'.format(name, host) + request_id = configurator.request_config( + hass, title, configuration_callback, + description='Functionality: ' + str(AXIS_INCLUDE), + entity_picture="/static/images/logo_axis.png", + link_name='Axis platform documentation', + link_url='https://home-assistant.io/components/axis/', + submit_caption="Confirm", + fields=[ + {'id': CONF_NAME, + 'name': "Device name", + 'type': 'text'}, + {'id': CONF_USERNAME, + 'name': "User name", + 'type': 'text'}, + {'id': CONF_PASSWORD, + 'name': 'Password', + 'type': 'password'}, + {'id': CONF_INCLUDE, + 'name': "Device functionality (space separated list)", + 'type': 'text'}, + {'id': ATTR_LOCATION, + 'name': "Physical location of device (optional)", + 'type': 'text'}, + {'id': CONF_TRIGGER_TIME, + 'name': "Sensor update interval (optional)", + 'type': 'number'}, + ] + ) + + +def setup(hass, base_config): + """Common setup for Axis devices.""" + def _shutdown(call): # pylint: disable=unused-argument + """Stop the metadatastream on shutdown.""" + for serialnumber, device in AXIS_DEVICES.items(): + _LOGGER.info("Stopping metadatastream for %s.", serialnumber) + device.stop_metadatastream() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + def axis_device_discovered(service, discovery_info): + """Called when axis devices has been found.""" + host = discovery_info['host'] + name = discovery_info['hostname'] + serialnumber = discovery_info['properties']['macaddress'] + + if serialnumber not in AXIS_DEVICES: + config_file = _read_config(hass) + if serialnumber in config_file: + try: + config = DEVICE_SCHEMA(config_file[serialnumber]) + except vol.Invalid as err: + _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) + return False + if not setup_device(hass, config): + _LOGGER.error("Couldn\'t set up %s", config['name']) + else: + request_configuration(hass, name, host, serialnumber) + + discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) + + if DOMAIN in base_config: + for device in base_config[DOMAIN]: + config = base_config[DOMAIN][device] + if CONF_NAME not in config: + config[CONF_NAME] = device + if not setup_device(hass, config): + _LOGGER.error("Couldn\'t set up %s", config['name']) + + return True + + +def setup_device(hass, config): + """Set up device.""" + from axis import AxisDevice + + config['hass'] = hass + device = AxisDevice(config) # Initialize device + enable_metadatastream = False + + if device.serial_number is None: + # If there is no serial number a connection could not be made + _LOGGER.error("Couldn\'t connect to %s", config[CONF_HOST]) + return False + + for component in config[CONF_INCLUDE]: + if component in EVENT_TYPES: + # Sensors are created by device calling event_initialized + # when receiving initialize messages on metadatastream + device.add_event_topic(convert(component, 'type', 'subscribe')) + if not enable_metadatastream: + enable_metadatastream = True + else: + discovery.load_platform(hass, component, DOMAIN, config) + + if enable_metadatastream: + device.initialize_new_event = event_initialized + device.initiate_metadatastream() + AXIS_DEVICES[device.serial_number] = device + return True + + +def _read_config(hass): + """Read Axis config.""" + path = hass.config.path(CONFIG_FILE) + + if not os.path.isfile(path): + return {} + + with open(path) as f_handle: + # Guard against empty file + return json.loads(f_handle.read() or '{}') + + +def _write_config(hass, config): + """Write Axis config.""" + data = json.dumps(config) + with open(hass.config.path(CONFIG_FILE), 'w', encoding='utf-8') as outfile: + outfile.write(data) + + +def event_initialized(event): + """Register event initialized on metadatastream here.""" + hass = event.device_config('hass') + discovery.load_platform(hass, + convert(event.topic, 'topic', 'platform'), + DOMAIN, {'axis_event': event}) + + +class AxisDeviceEvent(Entity): + """Representation of a Axis device event.""" + + def __init__(self, axis_event): + """Initialize the event.""" + self.axis_event = axis_event + self._event_class = convert(self.axis_event.topic, 'topic', 'class') + self._name = '{}_{}_{}'.format(self.axis_event.device_name, + convert(self.axis_event.topic, + 'topic', 'type'), + self.axis_event.id) + self.axis_event.callback = self._update_callback + + def _update_callback(self): + """Update the sensor's state, if needed.""" + self.update() + self.schedule_update_ha_state() + + @property + def name(self): + """Return the name of the event.""" + return self._name + + @property + def device_class(self): + """Return the class of the event.""" + return self._event_class + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the event.""" + attr = {} + + tripped = self.axis_event.is_tripped + attr[ATTR_TRIPPED] = 'True' if tripped else 'False' + + location = self.axis_event.device_config(ATTR_LOCATION) + if location: + attr[ATTR_LOCATION] = location + + return attr + + +def convert(item, from_key, to_key): + """Translate between Axis and HASS syntax.""" + for entry in REMAP: + if entry[from_key] == item: + return entry[to_key] + + +REMAP = [{'type': 'motion', + 'class': 'motion', + 'topic': 'tns1:VideoAnalytics/tnsaxis:MotionDetection', + 'subscribe': 'onvif:VideoAnalytics/axis:MotionDetection', + 'platform': 'binary_sensor'}, + {'type': 'vmd3', + 'class': 'motion', + 'topic': 'tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1', + 'subscribe': 'onvif:RuleEngine/axis:VMD3/vmd3_video_1', + 'platform': 'binary_sensor'}, + {'type': 'pir', + 'class': 'motion', + 'topic': 'tns1:Device/tnsaxis:Sensor/PIR', + 'subscribe': 'onvif:Device/axis:Sensor/axis:PIR', + 'platform': 'binary_sensor'}, + {'type': 'sound', + 'class': 'sound', + 'topic': 'tns1:AudioSource/tnsaxis:TriggerLevel', + 'subscribe': 'onvif:AudioSource/axis:TriggerLevel', + 'platform': 'binary_sensor'}, + {'type': 'daynight', + 'class': 'light', + 'topic': 'tns1:VideoSource/tnsaxis:DayNightVision', + 'subscribe': 'onvif:VideoSource/axis:DayNightVision', + 'platform': 'binary_sensor'}, + {'type': 'tampering', + 'class': 'safety', + 'topic': 'tns1:VideoSource/tnsaxis:Tampering', + 'subscribe': 'onvif:VideoSource/axis:Tampering', + 'platform': 'binary_sensor'}, + {'type': 'input', + 'class': 'input', + 'topic': 'tns1:Device/tnsaxis:IO/Port', + 'subscribe': 'onvif:Device/axis:IO/Port', + 'platform': 'sensor'}, ] diff --git a/homeassistant/components/binary_sensor/axis.py b/homeassistant/components/binary_sensor/axis.py new file mode 100644 index 00000000000..125e9b33bd7 --- /dev/null +++ b/homeassistant/components/binary_sensor/axis.py @@ -0,0 +1,68 @@ +""" +Support for Axis binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.axis/ +""" + +import logging +from datetime import timedelta + +from homeassistant.components.binary_sensor import (BinarySensorDevice) +from homeassistant.components.axis import (AxisDeviceEvent) +from homeassistant.const import (CONF_TRIGGER_TIME) +from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.util.dt import utcnow + +DEPENDENCIES = ['axis'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Axis device event.""" + add_devices([AxisBinarySensor(discovery_info['axis_event'], hass)], True) + + +class AxisBinarySensor(AxisDeviceEvent, BinarySensorDevice): + """Representation of a binary Axis event.""" + + def __init__(self, axis_event, hass): + """Initialize the binary sensor.""" + self.hass = hass + self._state = False + self._delay = axis_event.device_config(CONF_TRIGGER_TIME) + self._timer = None + AxisDeviceEvent.__init__(self, axis_event) + + @property + def is_on(self): + """Return true if event is active.""" + return self._state + + def update(self): + """Get the latest data and update the state.""" + self._state = self.axis_event.is_tripped + + def _update_callback(self): + """Update the sensor's state, if needed.""" + self.update() + + if self._timer is not None: + self._timer() + self._timer = None + + if self._delay > 0 and not self.is_on: + # Set timer to wait until updating the state + def _delay_update(now): + """Timer callback for sensor update.""" + _LOGGER.debug("%s Called delayed (%s sec) update.", + self._name, self._delay) + self.schedule_update_ha_state() + self._timer = None + + self._timer = track_point_in_utc_time( + self.hass, _delay_update, + utcnow() + timedelta(seconds=self._delay)) + else: + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py new file mode 100644 index 00000000000..3de1c568745 --- /dev/null +++ b/homeassistant/components/camera/axis.py @@ -0,0 +1,38 @@ +""" +Support for Axis camera streaming. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.axis/ +""" +import logging + +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) +from homeassistant.components.camera.mjpeg import ( + CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['axis'] +DOMAIN = 'axis' + + +def _get_image_url(host, mode): + if mode == 'mjpeg': + return 'http://{}/axis-cgi/mjpg/video.cgi'.format(host) + elif mode == 'single': + return 'http://{}/axis-cgi/jpg/image.cgi'.format(host) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Axis camera.""" + device_info = { + CONF_NAME: discovery_info['name'], + CONF_USERNAME: discovery_info['username'], + CONF_PASSWORD: discovery_info['password'], + CONF_MJPEG_URL: _get_image_url(discovery_info['host'], 'mjpeg'), + CONF_STILL_IMAGE_URL: _get_image_url(discovery_info['host'], 'single'), + CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, + } + add_devices([MjpegCamera(hass, device_info)]) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 58fc56d2cba..4641241ea51 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -31,6 +31,7 @@ SERVICE_WEMO = 'belkin_wemo' SERVICE_HASS_IOS_APP = 'hass_ios' SERVICE_IKEA_TRADFRI = 'ikea_tradfri' SERVICE_HASSIO = 'hassio' +SERVICE_AXIS = 'axis' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -38,6 +39,7 @@ SERVICE_HANDLERS = { SERVICE_WEMO: ('wemo', None), SERVICE_IKEA_TRADFRI: ('tradfri', None), SERVICE_HASSIO: ('hassio', None), + SERVICE_AXIS: ('axis', None), 'philips_hue': ('light', 'hue'), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), diff --git a/homeassistant/components/frontend/www_static/images/logo_axis.png b/homeassistant/components/frontend/www_static/images/logo_axis.png new file mode 100644 index 0000000000000000000000000000000000000000..5eeb9b7b2a78f2e0dda673e40910361ce9bda343 GIT binary patch literal 2858 zcmb7G3tLiI7v5mth^CWn%MzVRkrC7xr}9#%q3EcgWQqpfKseuFzWD=l&U2o9o^{sR>s{}D&)S=H$jfad z#tZ`hSV?ksJpup%eno&U(D2}&P;wd`&_Nz=w@96pWQ-k~n|K7E_GqIb7_iu2_4azK)zAfq; z#7PlFu^jv7c}`+Vk&$DeuKQR_9qag9?UiIp;$HMR_LK+aO5&TgCY+KgIG$~!i!(fA4MCaP7hjvh&t_VZ4 zpO+uc>gG*zAvx^%hFl8T>lIX=kC7;I5OP6fc|Z^%>?q50eoCQ$&+nHDGZ$loQxJC- zob@ce7|F@*hH869!3L&?PPCfuU5*?T7Nf!twj~O>sQ(wNbKOllpbWntd}*1*E5^ow z&K?onxEEq+LT0513b^2-BxYsh%vDX=TP34Y<=gP(NRFkU-}_|>0tLxAv2JR^0o78HRPsm{Y`Z!yp1d z7B64?KmRC>!Xa&a99W&C^0pdjCcCD?^>UiKiR^zU5$3;NmaPp(9R(W)G^ND8!blh~ zqe8Ex!q+nRH$#ShEOcV5K+%Hj24yRefdkD`}mWz9X z45?YVraF}aSI6k)&CInRIy*$XvA;9b0Wv+PEx!iC@h;lI^JzvLl1vdpnG4G$8S!xyaV^I`Dgx#?D&( zYy+$t_aa68QXSHefsBQixr#|LR7VItpctt^>W|EHH!4=dd^L4G`b_6{*rt4-I2l}}+d z5@P8jUc#}j2);4;akSX_t9djV^|Zig_S;j{X@j4{mHA?o;5N^{i^WecV)Uuq6lo)} zlo!``%^oc5-J?=H2wHA4ie6Z_Z#Xa6MPbzLpqm=a4x~;Qbo9` zMia%(sdJmAEy7W^pCVO;;1S(necRN5%I3V{GqYo*ZWC`CspPdLuQb3pXRyWAR(f1` zEq}+omFH)E_2DRd0`iw6)xR{S$XXiK<}HSOnDnA98^aYwE!@2xG3yaz4t6`Jvpmzb zKUlf=_0O-U{nMAZ$bJhj{y6nZl#RNqp)1X^&uGOYn;^#|SxF&*w|9IFYU$WBx7a=n@}$Z@WL{Nrk{USKHE&_^THq63A&%D&yb;HKLm@vu_OO5 z-to@>`I;Mw$PQYz*)=^6gDuone`Ds(qk#62mt1{xyO)8PNfou#yfr=bBCx+nGm#1B zD)MkIQWMLt?G0GmJiLXGq4GRjlyKirTe`|0fm>)ymMK&Rs`GGv)?+6=AZl<6$CT6_ z7bDY@nSQ#x?@70}qvXWSmY2kSqwp7xrH&BggIHN8PbEzUnk ze@LCYyT{iRU-P2yV{lMhXCWMwc_wfq_CK#i4i2#BZQ&J$$o0@%vbp9%(f;QHuL5Fe zWyr7x4~NhsgWLTW#R$k%h)&@<7x`Ak%({-gVnG1a4vaSWJ#Q*&W$iOEEv@!Z-Nbon zfUmW>Rs4gr4(>l}`(W=am4PD#)b45>V;Ehqr7^SgI3Pm;Eps0_@|F2L@Y#d zH9w#8F#NuNL-oeR%q~FH**JhvO zBJZ1no3xZ%TvR1C-}Ef7Rm*F=f@We*>qM|0mkR>cU>Wg4pD343YuP7NZxnw-Y>={T zSo~y{vMsIY8GZxHtrxc?6JvImnb0&-lg=(F;ruO5yWvCEJy=Z>s#}^nl<|5!V%l;c z;V)OprAl*P@#*lDBJF%9#D}BLdY-Sj{8Dy@hT56s^w@;jcA`Sl{(2jlZ9gh>FzR3Y z`qyii;Z8{}#$PfqcDbMNEOg)qzNSm3`HUu%f+vJ|HgF5!Bi=&u)_-v@gkASmLI~?$ W@)9Nwd>^NcB<=Tdt@ Date: Fri, 12 May 2017 18:19:51 +0200 Subject: [PATCH 081/135] LIFX: add lifx_set_state service call (#7552) * Move service helpers to LifxManager * Add lifx_set_color This is a synonym for light.turn_on except it does not actually turn on the light unless asked to. Thus, turn_on can be implemented just by asking. * Rename set_color to set_state * Support power=False with lifx_set_state --- .../components/light/lifx/__init__.py | 102 ++++++++++++++---- .../components/light/lifx/effects.py | 18 +--- .../components/light/lifx/services.yaml | 20 ++++ 3 files changed, 105 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/light/lifx/__init__.py b/homeassistant/components/light/lifx/__init__.py index f13934011e9..17512f5dd3b 100644 --- a/homeassistant/components/light/lifx/__init__.py +++ b/homeassistant/components/light/lifx/__init__.py @@ -9,6 +9,7 @@ import logging import asyncio import sys import math +from os import path from functools import partial from datetime import timedelta import async_timeout @@ -16,15 +17,18 @@ import async_timeout import voluptuous as vol from homeassistant.components.light import ( - Light, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_RGB_COLOR, + Light, DOMAIN, PLATFORM_SCHEMA, LIGHT_TURN_ON_SCHEMA, + ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_RGB_COLOR, ATTR_XY_COLOR, ATTR_COLOR_TEMP, ATTR_TRANSITION, ATTR_EFFECT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, SUPPORT_XY_COLOR, SUPPORT_TRANSITION, SUPPORT_EFFECT) +from homeassistant.config import load_yaml_config_file from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_temperature_kelvin_to_mired) from homeassistant import util from homeassistant.core import callback from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.service import extract_entity_ids import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util @@ -41,7 +45,10 @@ BULB_LATENCY = 500 CONF_SERVER = 'server' +SERVICE_LIFX_SET_STATE = 'lifx_set_state' + ATTR_HSBK = 'hsbk' +ATTR_POWER = 'power' BYTE_MAX = 255 SHORT_MAX = 65535 @@ -53,6 +60,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SERVER, default='0.0.0.0'): cv.string, }) +LIFX_SET_STATE_SCHEMA = LIGHT_TURN_ON_SCHEMA.extend({ + ATTR_POWER: cv.boolean, +}) + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -87,6 +98,41 @@ class LIFXManager(object): self.hass = hass self.async_add_devices = async_add_devices + @asyncio.coroutine + def async_service_handle(service): + """Apply a service.""" + tasks = [] + for light in self.service_to_entities(service): + if service.service == SERVICE_LIFX_SET_STATE: + task = light.async_set_state(**service.data) + tasks.append(hass.async_add_job(task)) + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + descriptions = self.get_descriptions() + + hass.services.async_register( + DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle, + descriptions.get(SERVICE_LIFX_SET_STATE), + schema=LIFX_SET_STATE_SCHEMA) + + @staticmethod + def get_descriptions(): + """Load and return descriptions for our own service calls.""" + return load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + def service_to_entities(self, service): + """Return the known devices that a service call mentions.""" + entity_ids = extract_entity_ids(self.hass, service) + if entity_ids: + entities = [entity for entity in self.entities.values() + if entity.entity_id in entity_ids] + else: + entities = list(self.entities.values()) + + return entities + @callback def register(self, device): """Handle for newly detected bulb.""" @@ -298,6 +344,18 @@ class LIFXLight(Light): @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn the device on.""" + kwargs[ATTR_POWER] = True + yield from self.async_set_state(**kwargs) + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the device off.""" + kwargs[ATTR_POWER] = False + yield from self.async_set_state(**kwargs) + + @asyncio.coroutine + def async_set_state(self, **kwargs): + """Set a color on the light and turn it on/off.""" yield from self.stop_effect() if ATTR_EFFECT in kwargs: @@ -309,39 +367,41 @@ class LIFXLight(Light): else: fade = 0 + # These are both False if ATTR_POWER is not set + power_on = kwargs.get(ATTR_POWER, False) + power_off = not kwargs.get(ATTR_POWER, True) + hsbk, changed_color = self.find_hsbk(**kwargs) _LOGGER.debug("turn_on: %s (%d) %d %d %d %d %d", self.who, self._power, fade, *hsbk) if self._power == 0: + if power_off: + self.device.set_power(False, None, 0) if changed_color: self.device.set_color(hsbk, None, 0) - self.device.set_power(True, None, fade) + if power_on: + self.device.set_power(True, None, fade) else: - self.device.set_power(True, None, 0) # racing for power status + if power_on: + self.device.set_power(True, None, 0) if changed_color: self.device.set_color(hsbk, None, fade) + if power_off: + self.device.set_power(False, None, fade) - self.update_later(0) - if fade < BULB_LATENCY: - self.set_power(1) - self.set_color(*hsbk) - - @asyncio.coroutine - def async_turn_off(self, **kwargs): - """Turn the device off.""" - yield from self.stop_effect() - - if ATTR_TRANSITION in kwargs: - fade = int(kwargs[ATTR_TRANSITION] * 1000) + if power_on: + self.update_later(0) else: - fade = 0 + self.update_later(fade) - self.device.set_power(False, None, fade) - - self.update_later(fade) - if fade < BULB_LATENCY: - self.set_power(0) + if fade <= BULB_LATENCY: + if power_on: + self.set_power(1) + if power_off: + self.set_power(0) + if changed_color: + self.set_color(*hsbk) @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index 85de663a817..315759738f9 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -2,16 +2,13 @@ import logging import asyncio import random -from os import path import voluptuous as vol from homeassistant.components.light import ( DOMAIN, ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_RGB_COLOR, ATTR_EFFECT, ATTR_TRANSITION) -from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_ENTITY_ID) -from homeassistant.helpers.service import extract_entity_ids import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -73,19 +70,12 @@ def setup(hass, lifx_manager): @asyncio.coroutine def async_service_handle(service): """Apply a service.""" - entity_ids = extract_entity_ids(hass, service) - if entity_ids: - devices = [entity for entity in lifx_manager.entities.values() - if entity.entity_id in entity_ids] - else: - devices = list(lifx_manager.entities.values()) - - if devices: - yield from start_effect(hass, devices, + entities = lifx_manager.service_to_entities(service) + if entities: + yield from start_effect(hass, entities, service.service, **service.data) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) + descriptions = lifx_manager.get_descriptions() hass.services.async_register( DOMAIN, SERVICE_EFFECT_BREATHE, async_service_handle, diff --git a/homeassistant/components/light/lifx/services.yaml b/homeassistant/components/light/lifx/services.yaml index d939e1432bc..a907a665753 100644 --- a/homeassistant/components/light/lifx/services.yaml +++ b/homeassistant/components/light/lifx/services.yaml @@ -1,3 +1,23 @@ +lifx_set_state: + description: Set a color/brightness and possibliy turn the light on/off + + fields: + entity_id: + description: Name(s) of entities to set a state on + example: 'light.garage' + + '...': + description: All turn_on parameters can be used to specify a color + + transition: + description: Duration in seconds it takes to get to the final state + example: 10 + + power: + description: Turn the light on (True) or off (False). Leave out to keep the power as it is. + example: True + + lifx_effect_breathe: description: Run a breathe effect by fading to a color and back. From a96a98a260f49d50c97195383f387d7090a40f46 Mon Sep 17 00:00:00 2001 From: florincosta Date: Fri, 12 May 2017 19:20:48 +0300 Subject: [PATCH 082/135] Add raspihats binary sensor (#7508) * Added raspihats binary_sensor platform * Updated .coveragerc to ommit raspihats platforms. * Using vol.Coerce(int) for validation and casting of I2CHat config address --- .coveragerc | 1 + .../components/binary_sensor/raspihats.py | 131 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 homeassistant/components/binary_sensor/raspihats.py diff --git a/.coveragerc b/.coveragerc index 60df26cf153..dfdff90dd51 100644 --- a/.coveragerc +++ b/.coveragerc @@ -87,6 +87,7 @@ omit = homeassistant/components/*/qwikswitch.py homeassistant/components/raspihats.py + homeassistant/components/*/raspihats.py homeassistant/components/rfxtrx.py homeassistant/components/*/rfxtrx.py diff --git a/homeassistant/components/binary_sensor/raspihats.py b/homeassistant/components/binary_sensor/raspihats.py new file mode 100644 index 00000000000..ad19fb525a1 --- /dev/null +++ b/homeassistant/components/binary_sensor/raspihats.py @@ -0,0 +1,131 @@ +""" +Configure a binary_sensor using a digital input from a raspihats board. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.raspihats/ +""" +import logging +import voluptuous as vol +from homeassistant.const import ( + CONF_NAME, CONF_DEVICE_CLASS, DEVICE_DEFAULT_NAME +) +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice +) +from homeassistant.components.raspihats import ( + CONF_I2C_HATS, CONF_BOARD, CONF_ADDRESS, CONF_CHANNELS, CONF_INDEX, + CONF_INVERT_LOGIC, I2C_HAT_NAMES, I2C_HATS_MANAGER, I2CHatsException +) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['raspihats'] + +DEFAULT_INVERT_LOGIC = False +DEFAULT_DEVICE_CLASS = None + +_CHANNELS_SCHEMA = vol.Schema([{ + vol.Required(CONF_INDEX): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, + vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): cv.string, +}]) + +_I2C_HATS_SCHEMA = vol.Schema([{ + vol.Required(CONF_BOARD): vol.In(I2C_HAT_NAMES), + vol.Required(CONF_ADDRESS): vol.Coerce(int), + vol.Required(CONF_CHANNELS): _CHANNELS_SCHEMA +}]) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_I2C_HATS): _I2C_HATS_SCHEMA, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the raspihats binary_sensor devices.""" + I2CHatBinarySensor.I2C_HATS_MANAGER = hass.data[I2C_HATS_MANAGER] + binary_sensors = [] + i2c_hat_configs = config.get(CONF_I2C_HATS) + for i2c_hat_config in i2c_hat_configs: + address = i2c_hat_config[CONF_ADDRESS] + board = i2c_hat_config[CONF_BOARD] + try: + I2CHatBinarySensor.I2C_HATS_MANAGER.register_board(board, address) + for channel_config in i2c_hat_config[CONF_CHANNELS]: + binary_sensors.append( + I2CHatBinarySensor( + address, + channel_config[CONF_INDEX], + channel_config[CONF_NAME], + channel_config[CONF_INVERT_LOGIC], + channel_config[CONF_DEVICE_CLASS] + ) + ) + except I2CHatsException as ex: + _LOGGER.error( + "Failed to register " + board + "I2CHat@" + hex(address) + " " + + str(ex) + ) + add_devices(binary_sensors) + + +class I2CHatBinarySensor(BinarySensorDevice): + """Represents a binary sensor that uses a I2C-HAT digital input.""" + + I2C_HATS_MANAGER = None + + def __init__(self, address, channel, name, invert_logic, device_class): + """Initialize sensor.""" + self._address = address + self._channel = channel + self._name = name or DEVICE_DEFAULT_NAME + self._invert_logic = invert_logic + self._device_class = device_class + self._state = self.I2C_HATS_MANAGER.read_di( + self._address, + self._channel + ) + + def online_callback(): + """Callback fired when board is online.""" + self.schedule_update_ha_state() + + self.I2C_HATS_MANAGER.register_online_callback( + self._address, + self._channel, + online_callback + ) + + def edge_callback(state): + """Read digital input state.""" + self._state = state + self.schedule_update_ha_state() + + self.I2C_HATS_MANAGER.register_di_callback( + self._address, + self._channel, + edge_callback + ) + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + @property + def name(self): + """Return the name of this sensor.""" + return self._name + + @property + def should_poll(self): + """Polling not needed for this sensor.""" + return False + + @property + def is_on(self): + """Return the state of this sensor.""" + return self._state != self._invert_logic From fdb73712564682fbbbec6b678d6d59d6078184a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Fri, 12 May 2017 18:25:34 +0200 Subject: [PATCH 083/135] update pywebpush to 1.0.0 (#7561) --- homeassistant/components/notify/html5.py | 3 +-- requirements_all.txt | 5 +---- requirements_test_all.txt | 5 +---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 3aa5db7faba..52d2deedcd4 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -25,8 +25,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.frontend import add_manifest_json_key from homeassistant.helpers import config_validation as cv -# pyelliptic is dependency of pywebpush and 1.5.8 contains a breaking change -REQUIREMENTS = ['pywebpush==0.6.1', 'PyJWT==1.4.2', 'pyelliptic==1.5.7'] +REQUIREMENTS = ['pywebpush==1.0.0', 'PyJWT==1.4.2'] DEPENDENCIES = ['frontend'] diff --git a/requirements_all.txt b/requirements_all.txt index 975ea766d3a..08e6f85ebb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -529,9 +529,6 @@ pyebox==0.1.0 # homeassistant.components.eight_sleep pyeight==0.0.4 -# homeassistant.components.notify.html5 -pyelliptic==1.5.7 - # homeassistant.components.media_player.emby pyemby==1.2 @@ -714,7 +711,7 @@ pyunifi==2.12 pyvera==0.2.30 # homeassistant.components.notify.html5 -pywebpush==0.6.1 +pywebpush==1.0.0 # homeassistant.components.wemo pywemo==0.4.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 238de072c12..ef73b6d3621 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,9 +106,6 @@ pycmus==0.1.0 # homeassistant.components.zwave pydispatcher==2.0.5 -# homeassistant.components.notify.html5 -pyelliptic==1.5.7 - # homeassistant.components.litejet pylitejet==0.1 @@ -126,7 +123,7 @@ python-forecastio==1.3.5 pyunifi==2.12 # homeassistant.components.notify.html5 -pywebpush==0.6.1 +pywebpush==1.0.0 # homeassistant.components.rflink rflink==0.0.34 From 4a3048b370cca4aa6aae88d9114587ba50cba9cc Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Sat, 13 May 2017 01:04:30 +0200 Subject: [PATCH 084/135] Initialize sun with correct values. (#7559) * Initialize sun with unknown values. Initial values should be `unknown` instead of `0`. Otherwise on HA restart the value of `0` is pushed to metrics databases (graphite/influx/recorder). * Update sun position before emitting initial update * Simplify based on armills comment. * Use provided time for calculation. --- homeassistant/components/sun.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index 8254b4b2f0e..9d3d82bd8fc 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -62,7 +62,7 @@ class Sun(Entity): self._state = self.next_rising = self.next_setting = None self.next_dawn = self.next_dusk = None self.next_midnight = self.next_noon = None - self.solar_elevation = self.solar_azimuth = 0 + self.solar_elevation = self.solar_azimuth = None async_track_utc_time_change(hass, self.timer_update, second=30) @@ -124,6 +124,7 @@ class Sun(Entity): @callback def point_in_time_listener(self, now): """Run when the state of the sun has changed.""" + self.update_sun_position(now) self.update_as_of(now) self.hass.async_add_job(self.async_update_ha_state()) From ed0ec613c3c2dbd2d891d9247492f4e88ce9a517 Mon Sep 17 00:00:00 2001 From: Juggels Date: Sat, 13 May 2017 05:06:28 +0200 Subject: [PATCH 085/135] Comment RasPi specific requirements (#7562) --- homeassistant/components/sensor/envirophat.py | 1 + requirements_all.txt | 4 ++-- script/gen_requirements_all.py | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index fa694d837f0..f2db833954f 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -55,6 +55,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sense HAT sensor platform.""" try: + # pylint: disable=import-error import envirophat except OSError: _LOGGER.error("No Enviro pHAT was found.") diff --git a/requirements_all.txt b/requirements_all.txt index 08e6f85ebb4..7e57538711e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -172,7 +172,7 @@ eliqonline==1.0.13 enocean==0.31 # homeassistant.components.sensor.envirophat -envirophat==0.0.6 +# envirophat==0.0.6 # homeassistant.components.keyboard_remote # evdev==0.6.1 @@ -775,7 +775,7 @@ sleekxmpp==1.3.2 sleepyq==0.6 # homeassistant.components.sensor.envirophat -smbus-cffi==0.5.1 +# smbus-cffi==0.5.1 # homeassistant.components.media_player.snapcast snapcast==1.2.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 64091c5c2cc..59299f36453 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -27,6 +27,8 @@ COMMENT_REQUIREMENTS = ( 'decora', 'face_recognition', 'blinkt', + 'smbus-cffi', + 'envirophat' ) TEST_REQUIREMENTS = ( From f0ce6c82102bc45fddda52064da20efd7b5ec844 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 12 May 2017 20:14:17 -0700 Subject: [PATCH 086/135] Update netdisco (#7563) --- homeassistant/components/discovery.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 3 --- script/gen_requirements_all.py | 1 - tests/components/test_discovery.py | 12 +++++++++++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 4641241ea51..261d8953940 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.0.0rc3'] +REQUIREMENTS = ['netdisco==1.0.0'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index 7e57538711e..2a10480f3a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -385,7 +385,7 @@ mutagen==1.37.0 myusps==1.0.5 # homeassistant.components.discovery -netdisco==1.0.0rc3 +netdisco==1.0.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef73b6d3621..c6375e4b232 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,9 +78,6 @@ mficlient==0.3.0 # homeassistant.components.tts mutagen==1.37.0 -# homeassistant.components.discovery -netdisco==1.0.0rc3 - # homeassistant.components.mqtt paho-mqtt==1.2.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 59299f36453..e1f005a3668 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,7 +50,6 @@ TEST_REQUIREMENTS = ( 'pilight', 'fuzzywuzzy', 'datadog', - 'netdisco', 'rflink', 'ring_doorbell', 'sleepyq', diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index d5be9c483ad..580d876982d 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -1,8 +1,9 @@ """The tests for the discovery component.""" import asyncio import os +from unittest.mock import patch, MagicMock -from unittest.mock import patch +import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery @@ -34,6 +35,15 @@ IGNORE_CONFIG = { } +@pytest.fixture(autouse=True) +def netdisco_mock(): + """Mock netdisco.""" + with patch.dict('sys.modules', { + 'netdisco.discovery': MagicMock(), + }): + yield + + @asyncio.coroutine def mock_discovery(hass, discoveries, config=BASE_CONFIG): """Helper to mock discoveries.""" From 11a3dc268fca614c760f11ff546c992327f74e70 Mon Sep 17 00:00:00 2001 From: Mitesh Patel Date: Fri, 12 May 2017 22:17:11 -0500 Subject: [PATCH 087/135] Support lutron serena shades (#7565) * Adds support for the Lutron Caseta Serena shades hardware * fixes typos --- .../components/cover/lutron_caseta.py | 62 +++++++++++++++++++ homeassistant/components/lutron_caseta.py | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/cover/lutron_caseta.py diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py new file mode 100644 index 00000000000..2c411c61ba4 --- /dev/null +++ b/homeassistant/components/cover/lutron_caseta.py @@ -0,0 +1,62 @@ +""" +Support for Lutron Caseta SerenaRollerShade. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.lutron_caseta/ +""" +import logging + + +from homeassistant.components.cover import ( + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.components.lutron_caseta import ( + LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) + + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['lutron_caseta'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Lutron Caseta Serena shades as a cover device.""" + devs = [] + bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] + cover_devices = bridge.get_devices_by_types(["SerenaRollerShade"]) + for cover_device in cover_devices: + dev = LutronCasetaCover(cover_device, bridge) + devs.append(dev) + + add_devices(devs, True) + + +class LutronCasetaCover(LutronCasetaDevice, CoverDevice): + """Representation of a Lutron Serena shade.""" + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._state["current_state"] < 1 + + def close_cover(self): + """Close the cover.""" + self._smartbridge.set_value(self._device_id, 0) + + def open_cover(self): + """Open the cover.""" + self._smartbridge.set_value(self._device_id, 100) + + def set_cover_position(self, position, **kwargs): + """Move the roller shutter to a specific position.""" + self._smartbridge.set_value(self._device_id, position) + + def 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/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 7dd5c647213..e2ad733fb36 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -44,7 +44,7 @@ def setup(hass, base_config): _LOGGER.info("Connected to Lutron smartbridge at %s", config[CONF_HOST]) - for component in ('light', 'switch'): + for component in ('light', 'switch', 'cover'): discovery.load_platform(hass, component, DOMAIN, {}, config) return True From c118be6639cebf59219ad78cd99f16b813479fbf Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 12 May 2017 20:18:20 -0700 Subject: [PATCH 088/135] Tests for zwave discovery logic (#7566) * Tests for zwave discovery logic * Simplify patching * Test ignored node --- tests/components/zwave/test_init.py | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 91902f9c4a8..a3327d6d558 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -138,6 +138,122 @@ def test_device_entity(hass, mock_openzwave): assert device.device_state_attributes[zwave.ATTR_POWER] == 50.123 +@asyncio.coroutine +def test_node_discovery(hass, mock_openzwave): + """Test discovery of a node.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_NODE_ADDED: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + + assert len(mock_receivers) == 1 + + node = MockNode(node_id=14) + hass.async_add_job(mock_receivers[0], node) + yield from hass.async_block_till_done() + + assert hass.states.get('zwave.mock_node_14').state is 'unknown' + + +@asyncio.coroutine +def test_node_ignored(hass, mock_openzwave): + """Test discovery of a node.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_NODE_ADDED: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + yield from async_setup_component(hass, 'zwave', {'zwave': { + 'device_config': { + 'zwave.mock_node_14': { + 'ignored': True, + }}}}) + + assert len(mock_receivers) == 1 + + node = MockNode(node_id=14) + hass.async_add_job(mock_receivers[0], node) + yield from hass.async_block_till_done() + + assert hass.states.get('zwave.mock_node_14') is None + + +@asyncio.coroutine +def test_value_discovery(hass, mock_openzwave): + """Test discovery of a node.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_VALUE_ADDED: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + + assert len(mock_receivers) == 1 + + node = MockNode(node_id=11, generic=const.GENERIC_TYPE_SENSOR_BINARY) + value = MockValue(data=False, node=node, index=12, instance=13, + command_class=const.COMMAND_CLASS_SENSOR_BINARY, + type=const.TYPE_BOOL, genre=const.GENRE_USER) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + + assert hass.states.get( + 'binary_sensor.mock_node_mock_value_11_12_13').state is 'off' + + +@asyncio.coroutine +def test_value_discovery_existing_entity(hass, mock_openzwave): + """Test discovery of a node.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_VALUE_ADDED: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + + assert len(mock_receivers) == 1 + + node = MockNode(node_id=11, generic=const.GENERIC_TYPE_THERMOSTAT) + setpoint = MockValue( + data=22.0, node=node, index=12, instance=13, + command_class=const.COMMAND_CLASS_THERMOSTAT_SETPOINT, + genre=const.GENRE_USER, units='C') + hass.async_add_job(mock_receivers[0], node, setpoint) + yield from hass.async_block_till_done() + + assert hass.states.get('climate.mock_node_mock_value_11_12_13').attributes[ + 'temperature'] == 22.0 + assert hass.states.get('climate.mock_node_mock_value_11_12_13').attributes[ + 'current_temperature'] is None + + def mock_update(self): + self.hass.async_add_job(self.async_update_ha_state) + + with patch.object(zwave.node_entity.ZWaveBaseEntity, + 'maybe_schedule_update', new=mock_update): + temperature = MockValue( + data=23.5, node=node, index=12, instance=13, + command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL, + label='Temperature', genre=const.GENRE_USER, units='C') + hass.async_add_job(mock_receivers[0], node, temperature) + yield from hass.async_block_till_done() + + assert hass.states.get('climate.mock_node_mock_value_11_12_13').attributes[ + 'temperature'] == 22.0 + assert hass.states.get('climate.mock_node_mock_value_11_12_13').attributes[ + 'current_temperature'] == 23.5 + + class TestZWaveDeviceEntityValues(unittest.TestCase): """Tests for the ZWaveDeviceEntityValues helper.""" From 189023821bd492b8631382b0b4b076b39b24620e Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 12 May 2017 20:27:44 -0700 Subject: [PATCH 089/135] Tests for zwave setup features (#7570) * Tests for zwave setup features * Add test for frontend panel register --- tests/components/zwave/test_init.py | 68 ++++++++++++++++++++++++++++- tests/conftest.py | 3 +- tests/mock/zwave.py | 20 ++++++++- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index a3327d6d558..9f4e9b92c68 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -1,6 +1,7 @@ """Tests for the Z-Wave init.""" import asyncio from collections import OrderedDict +from datetime import datetime from homeassistant.bootstrap import async_setup_component from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START @@ -14,7 +15,8 @@ import pytest import unittest from unittest.mock import patch, MagicMock -from tests.common import get_test_home_assistant +from tests.common import ( + get_test_home_assistant, async_fire_time_changed, mock_http_component) from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues @@ -69,6 +71,70 @@ def test_config_access_error(): assert result is None +@asyncio.coroutine +def test_network_options(hass, mock_openzwave): + """Test network options.""" + result = yield from async_setup_component(hass, 'zwave', { + 'zwave': { + 'usb_path': 'mock_usb_path', + 'config_path': 'mock_config_path', + }}) + + assert result + + network = hass.data[zwave.ZWAVE_NETWORK] + assert network.options.device == 'mock_usb_path' + assert network.options.config_path == 'mock_config_path' + + +@asyncio.coroutine +def test_auto_heal_midnight(hass, mock_openzwave): + """Test network auto-heal at midnight.""" + assert (yield from async_setup_component(hass, 'zwave', { + 'zwave': { + 'autoheal': True, + }})) + network = hass.data[zwave.ZWAVE_NETWORK] + assert not network.heal.called + + time = datetime(2017, 5, 6, 0, 0, 0) + async_fire_time_changed(hass, time) + yield from hass.async_block_till_done() + assert network.heal.called + assert len(network.heal.mock_calls) == 1 + + +@asyncio.coroutine +def test_auto_heal_disabled(hass, mock_openzwave): + """Test network auto-heal disabled.""" + assert (yield from async_setup_component(hass, 'zwave', { + 'zwave': { + 'autoheal': False, + }})) + network = hass.data[zwave.ZWAVE_NETWORK] + assert not network.heal.called + + time = datetime(2017, 5, 6, 0, 0, 0) + async_fire_time_changed(hass, time) + yield from hass.async_block_till_done() + assert not network.heal.called + + +@asyncio.coroutine +def test_frontend_panel_register(hass, mock_openzwave): + """Test network auto-heal disabled.""" + mock_http_component(hass) + hass.config.components |= set(['frontend']) + with patch('homeassistant.components.zwave.' + 'register_built_in_panel') as mock_register: + assert (yield from async_setup_component(hass, 'zwave', { + 'zwave': { + 'autoheal': False, + }})) + assert mock_register.called + assert len(mock_register.mock_calls) == 1 + + @asyncio.coroutine def test_setup_platform(hass, mock_openzwave): """Test invalid device config.""" diff --git a/tests/conftest.py b/tests/conftest.py index b6c9795f127..07564e86c79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components import mqtt from tests.common import async_test_home_assistant, mock_coro from tests.test_util.aiohttp import mock_aiohttp_client -from tests.mock.zwave import MockNetwork +from tests.mock.zwave import MockNetwork, MockOption if os.environ.get('UVLOOP') == '1': import uvloop @@ -101,6 +101,7 @@ def mock_openzwave(): libopenzwave = base_mock.libopenzwave libopenzwave.__file__ = 'test' base_mock.network.ZWaveNetwork = MockNetwork + base_mock.option.ZWaveOption = MockOption with patch.dict('sys.modules', { 'libopenzwave': libopenzwave, diff --git a/tests/mock/zwave.py b/tests/mock/zwave.py index 513c606aab2..672cc884904 100644 --- a/tests/mock/zwave.py +++ b/tests/mock/zwave.py @@ -32,6 +32,23 @@ def notification(node_id, network=None): ) +class MockOption(MagicMock): + """Mock Z-Wave options.""" + + def __init__(self, device=None, config_path=None, user_path=None, + cmd_line=None): + """Initialize a Z-Wave mock options.""" + super().__init__() + self.device = device + self.config_path = config_path + self.user_path = user_path + self.cmd_line = cmd_line + + def _get_child_mock(self, **kw): + """Create child mocks with right MagicMock class.""" + return MagicMock(**kw) + + class MockNetwork(MagicMock): """Mock Z-Wave network.""" @@ -84,9 +101,10 @@ class MockNetwork(MagicMock): STATE_AWAKED = 7 STATE_READY = 10 - def __init__(self, *args, **kwargs): + def __init__(self, options=None, *args, **kwargs): """Initialize a Z-Wave mock network.""" super().__init__() + self.options = options self.state = MockNetwork.STATE_STOPPED From 25cb7c652bf1a266829eea142196d1b9fdcec5a2 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Fri, 12 May 2017 23:30:07 -0400 Subject: [PATCH 090/135] Blink version bump (#7571) Bumped blink version to support automatic reauthorization when tokens expire. Changed the battery sensor call to a string version so that the battery reports back "Low" or "OK" rather than a cryptic integer --- homeassistant/components/blink.py | 2 +- homeassistant/components/sensor/blink.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blink.py b/homeassistant/components/blink.py index 4ae5007d665..a44f0163787 100644 --- a/homeassistant/components/blink.py +++ b/homeassistant/components/blink.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, ATTR_FRIENDLY_NAME, ATTR_ARMED) from homeassistant.helpers import discovery -REQUIREMENTS = ['blinkpy==0.5.2'] +REQUIREMENTS = ['blinkpy==0.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/blink.py b/homeassistant/components/sensor/blink.py index e434776ffc6..44557978117 100644 --- a/homeassistant/components/sensor/blink.py +++ b/homeassistant/components/sensor/blink.py @@ -77,7 +77,7 @@ class BlinkSensor(Entity): if self._type == 'temperature': self._state = camera.temperature elif self._type == 'battery': - self._state = camera.battery + self._state = camera.battery_string elif self._type == 'notifications': self._state = camera.notifications else: diff --git a/requirements_all.txt b/requirements_all.txt index 2a10480f3a5..6a684cfc310 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -90,7 +90,7 @@ beautifulsoup4==4.6.0 bellows==0.2.7 # homeassistant.components.blink -blinkpy==0.5.2 +blinkpy==0.6.0 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 From ad15844cf422f9e7d4e39513f3e274e7de03dc0a Mon Sep 17 00:00:00 2001 From: bestlibre Date: Sat, 13 May 2017 05:47:12 +0200 Subject: [PATCH 091/135] Fix systematic warning in influxdb sensor (#7541) --- homeassistant/components/sensor/influxdb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/influxdb.py b/homeassistant/components/sensor/influxdb.py index b4688c77e1b..d1d693543be 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -174,7 +174,7 @@ class InfluxSensorData(object): "to UNKNOWN: %s", self.query) self.value = None else: - if points: + if len(points) > 1: _LOGGER.warning("Query returned multiple points, only first " "one shown: %s", self.query) self.value = points[0].get('value') From 4cdf0b496935eeccbda2d2c1ae10b8bd89d5537a Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Sat, 13 May 2017 05:48:57 +0200 Subject: [PATCH 092/135] Fix Kodi specific services registry and add descriptions (#7551) * Fix Kodi specific services, add descriptions, add more handled exceptions - Fixes issue #7528 - Add descriptions for Kodi specific services in services.yaml. - Error handling in Kodi API errors. - Make compatible the existent specific service `media_player.kodi_set_shuffle` with the general `media_player.shuffle_set` service (both use the same method but with different named parameter, I think the Kodi specific service should be eliminated, since it is not) * fix line too long * removed new services (for another PR); removed `kodi_set_shuffle` service * requested changes - Removed `kodi_set_shuffle` service. - Optional `media_name` and `artist_name` parameters. `media_name` defaults to 'ALL'. - Guard clause to check if the services are already registered. --- homeassistant/components/media_player/kodi.py | 103 ++++++++++++------ .../components/media_player/services.yaml | 20 ++++ 2 files changed, 89 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 10d13002625..18c01c396ac 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -9,16 +9,18 @@ from functools import wraps import logging import urllib import re +import os import aiohttp import voluptuous as vol +from homeassistant.config import load_yaml_config_file from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, - SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_VOLUME_STEP, MediaPlayerDevice, - PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, - MEDIA_TYPE_PLAYLIST, MEDIA_PLAYER_SCHEMA, DOMAIN) + SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_VOLUME_STEP, SUPPORT_SHUFFLE_SET, + MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_VIDEO, MEDIA_TYPE_PLAYLIST, MEDIA_PLAYER_SCHEMA, DOMAIN) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, CONF_PROXY_SSL, CONF_USERNAME, CONF_PASSWORD, @@ -61,8 +63,9 @@ MEDIA_TYPES = { } SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ - SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_VOLUME_STEP + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PLAY | SUPPORT_VOLUME_STEP PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -78,17 +81,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) SERVICE_ADD_MEDIA = 'kodi_add_to_playlist' -SERVICE_SET_SHUFFLE = 'kodi_set_shuffle' + +DATA_KODI = 'kodi' ATTR_MEDIA_TYPE = 'media_type' ATTR_MEDIA_NAME = 'media_name' ATTR_MEDIA_ARTIST_NAME = 'artist_name' ATTR_MEDIA_ID = 'media_id' -MEDIA_PLAYER_SET_SHUFFLE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ - vol.Required('shuffle_on'): cv.boolean, -}) - MEDIA_PLAYER_ADD_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ vol.Required(ATTR_MEDIA_TYPE): cv.string, vol.Optional(ATTR_MEDIA_ID): cv.string, @@ -100,15 +100,14 @@ SERVICE_TO_METHOD = { SERVICE_ADD_MEDIA: { 'method': 'async_add_media_to_playlist', 'schema': MEDIA_PLAYER_ADD_MEDIA_SCHEMA}, - SERVICE_SET_SHUFFLE: { - 'method': 'async_set_shuffle', - 'schema': MEDIA_PLAYER_SET_SHUFFLE_SCHEMA}, } @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Kodi platform.""" + if DATA_KODI not in hass.data: + hass.data[DATA_KODI] = [] host = config.get(CONF_HOST) port = config.get(CONF_PORT) tcp_port = config.get(CONF_TCP_PORT) @@ -130,6 +129,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): password=config.get(CONF_PASSWORD), turn_off_action=config.get(CONF_TURN_OFF_ACTION), websocket=websocket) + hass.data[DATA_KODI].append(entity) async_add_devices([entity], update_before_add=True) @asyncio.coroutine @@ -141,23 +141,37 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): params = {key: value for key, value in service.data.items() if key != 'entity_id'} - - yield from getattr(entity, method['method'])(**params) + entity_ids = service.data.get('entity_id') + if entity_ids: + target_players = [player for player in hass.data[DATA_KODI] + if player.entity_id in entity_ids] + else: + target_players = hass.data[DATA_KODI] update_tasks = [] - if entity.should_poll: - update_coro = entity.async_update_ha_state(True) - update_tasks.append(update_coro) + for player in target_players: + yield from getattr(player, method['method'])(**params) + + for player in target_players: + if player.should_poll: + update_coro = player.async_update_ha_state(True) + update_tasks.append(update_coro) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) + if hass.services.has_service(DOMAIN, SERVICE_ADD_MEDIA): + return + + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + for service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[service].get( - 'schema', MEDIA_PLAYER_SCHEMA) + schema = SERVICE_TO_METHOD[service]['schema'] hass.services.async_register( DOMAIN, service, async_service_handler, - description=None, schema=schema) + description=descriptions.get(service), schema=schema) def cmd(func): @@ -657,16 +671,16 @@ class KodiDevice(MediaPlayerDevice): {"item": {"file": str(media_id)}}) @asyncio.coroutine - def async_set_shuffle(self, shuffle_on): + def async_set_shuffle(self, shuffle): """Set shuffle mode, for the first player.""" if len(self._players) < 1: raise RuntimeError("Error: No active player.") yield from self.server.Player.SetShuffle( - {"playerid": self._players[0]['playerid'], "shuffle": shuffle_on}) + {"playerid": self._players[0]['playerid'], "shuffle": shuffle}) @asyncio.coroutine def async_add_media_to_playlist( - self, media_type, media_id=None, media_name='', artist_name=''): + self, media_type, media_id=None, media_name='ALL', artist_name=''): """Add a media to default playlist (i.e. playlistid=0). First the media type must be selected, then @@ -675,13 +689,14 @@ class KodiDevice(MediaPlayerDevice): All the albums of an artist can be added with media_name="ALL" """ + import jsonrpc_base + params = {"playlistid": 0} if media_type == "SONG": if media_id is None: media_id = yield from self.async_find_song( media_name, artist_name) - - yield from self.server.Playlist.Add( - {"playlistid": 0, "item": {"songid": int(media_id)}}) + if media_id: + params["item"] = {"songid": int(media_id)} elif media_type == "ALBUM": if media_id is None: @@ -691,12 +706,22 @@ class KodiDevice(MediaPlayerDevice): media_id = yield from self.async_find_album( media_name, artist_name) + if media_id: + params["item"] = {"albumid": int(media_id)} - yield from self.server.Playlist.Add( - {"playlistid": 0, "item": {"albumid": int(media_id)}}) else: raise RuntimeError("Unrecognized media type.") + if media_id is not None: + try: + yield from self.server.Playlist.Add(params) + except jsonrpc_base.jsonrpc.ProtocolError as exc: + result = exc.args[2]['error'] + _LOGGER.error('Run API method %s.Playlist.Add(%s) error: %s', + self.entity_id, media_type, result) + else: + _LOGGER.warning('No media detected for Playlist.Add') + @asyncio.coroutine def async_add_all_albums(self, artist_name): """Add all albums of an artist to default playlist (i.e. playlistid=0). @@ -734,9 +759,13 @@ class KodiDevice(MediaPlayerDevice): def async_find_artist(self, artist_name): """Find artist by name.""" artists = yield from self.async_get_artists() - out = self._find( - artist_name, [a['artist'] for a in artists['artists']]) - return artists['artists'][out[0][0]]['artistid'] + try: + out = self._find( + artist_name, [a['artist'] for a in artists['artists']]) + return artists['artists'][out[0][0]]['artistid'] + except KeyError: + _LOGGER.warning('No artists were found: %s', artist_name) + return None @asyncio.coroutine def async_get_songs(self, artist_id=None): @@ -769,8 +798,14 @@ class KodiDevice(MediaPlayerDevice): artist_id = yield from self.async_find_artist(artist_name) albums = yield from self.async_get_albums(artist_id) - out = self._find(album_name, [a['label'] for a in albums['albums']]) - return albums['albums'][out[0][0]]['albumid'] + try: + out = self._find( + album_name, [a['label'] for a in albums['albums']]) + return albums['albums'][out[0][0]]['albumid'] + except KeyError: + _LOGGER.warning('No albums were found with artist: %s, album: %s', + artist_name, album_name) + return None @staticmethod def _find(key_word, words): diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index ae90e141289..4d5f85c05eb 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -269,3 +269,23 @@ soundtouch_remove_zone_slave: slaves: description: Name of slaves entities to remove from the existing zone example: 'media_player.soundtouch_bedroom' + +kodi_add_to_playlist: + description: Add music to the default playlist (i.e. playlistid=0). + + fields: + entity_id: + description: Name(s) of the Kodi entities where to add the media. + example: 'media_player.living_room_kodi' + media_type: + description: Media type identifier. It must be one of SONG or ALBUM. + example: ALBUM + media_id: + description: Unique Id of the media entry to add (`songid` or albumid`). If not defined, `media_name` and `artist_name` are needed to search the Kodi music library. + example: 123456 + media_name: + description: Optional media name for filtering media. Can be 'ALL' when `media_type` is 'ALBUM' and `artist_name` is specified, to add all songs from one artist. + example: 'Highway to Hell' + artist_name: + description: Optional artist name for filtering media. + example: 'AC/DC' From 9c4bc2a47f520fbda2db258624e97e39f1fb6330 Mon Sep 17 00:00:00 2001 From: Stu Gott Date: Sat, 13 May 2017 00:12:47 -0400 Subject: [PATCH 093/135] Add Kira component to sensor and remote platforms (#7479) * Add Kira component to sensor and remote platforms * Test cases for Kira component and platforms --- .coveragerc | 3 + homeassistant/components/kira.py | 142 ++++++++++++++++++ homeassistant/components/remote/kira.py | 79 ++++++++++ homeassistant/components/remote/services.yaml | 4 +- homeassistant/components/sensor/kira.py | 79 ++++++++++ requirements_all.txt | 3 + tests/components/remote/test_kira.py | 57 +++++++ tests/components/sensor/test_kira.py | 59 ++++++++ tests/components/test_kira.py | 85 +++++++++++ tests/testing_config/kira_codes.yaml | 0 10 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/kira.py create mode 100755 homeassistant/components/remote/kira.py create mode 100644 homeassistant/components/sensor/kira.py create mode 100644 tests/components/remote/test_kira.py create mode 100644 tests/components/sensor/test_kira.py create mode 100644 tests/components/test_kira.py create mode 100644 tests/testing_config/kira_codes.yaml diff --git a/.coveragerc b/.coveragerc index dfdff90dd51..5b11aea6fd8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -62,6 +62,9 @@ omit = homeassistant/components/isy994.py homeassistant/components/*/isy994.py + homeassistant/components/kira.py + homeassistant/components/*/kira.py + homeassistant/components/lutron.py homeassistant/components/*/lutron.py diff --git a/homeassistant/components/kira.py b/homeassistant/components/kira.py new file mode 100644 index 00000000000..98d1228d541 --- /dev/null +++ b/homeassistant/components/kira.py @@ -0,0 +1,142 @@ +"""KIRA interface to receive UDP packets from an IR-IP bridge.""" +# pylint: disable=import-error +import logging +import os +import yaml + +import voluptuous as vol +from voluptuous.error import Error as VoluptuousError + +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SENSORS, + CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, + STATE_UNKNOWN) + +REQUIREMENTS = ["pykira==0.1.1"] + +DOMAIN = 'kira' + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 65432 + +CONF_CODE = "code" +CONF_REPEAT = "repeat" +CONF_REMOTES = "remotes" +CONF_SENSOR = "sensor" +CONF_REMOTE = "remote" + +CODES_YAML = '{}_codes.yaml'.format(DOMAIN) + +CODE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CODE): cv.string, + vol.Optional(CONF_TYPE): cv.string, + vol.Optional(CONF_DEVICE): cv.string, + vol.Optional(CONF_REPEAT): cv.positive_int, +}) + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME, default=DOMAIN): + vol.Exclusive(cv.string, "sensors"), + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + +REMOTE_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME, default=DOMAIN): + vol.Exclusive(cv.string, "remotes"), + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_SENSORS): [SENSOR_SCHEMA], + vol.Optional(CONF_REMOTES): [REMOTE_SCHEMA]}) +}, extra=vol.ALLOW_EXTRA) + + +def load_codes(path): + """Load Kira codes from specified file.""" + codes = [] + if os.path.exists(path): + with open(path) as code_file: + data = yaml.load(code_file) or [] + for code in data: + try: + codes.append(CODE_SCHEMA(code)) + except VoluptuousError as exception: + # keep going + _LOGGER.warning('Kira Code Invalid Data: %s', exception) + else: + with open(path, 'w') as code_file: + code_file.write('') + return codes + + +def setup(hass, config): + """Setup KIRA capability.""" + import pykira + + sensors = config.get(DOMAIN, {}).get(CONF_SENSORS, []) + remotes = config.get(DOMAIN, {}).get(CONF_REMOTES, []) + # If no sensors or remotes were specified, add a sensor + if not(sensors or remotes): + sensors.append({}) + + codes = load_codes(hass.config.path(CODES_YAML)) + + hass.data[DOMAIN] = { + CONF_SENSOR: {}, + CONF_REMOTE: {}, + } + + def load_module(platform, idx, module_conf): + """Set up Kira module and load platform.""" + # note: module_name is not the HA device name. it's just a unique name + # to ensure the component and platform can share information + module_name = ("%s_%d" % (DOMAIN, idx)) if idx else DOMAIN + device_name = module_conf.get(CONF_NAME, DOMAIN) + port = module_conf.get(CONF_PORT, DEFAULT_PORT) + host = module_conf.get(CONF_HOST, DEFAULT_HOST) + + if platform == CONF_SENSOR: + module = pykira.KiraReceiver(host, port) + module.start() + else: + module = pykira.KiraModule(host, port) + + hass.data[DOMAIN][platform][module_name] = module + for code in codes: + code_tuple = (code.get(CONF_NAME), + code.get(CONF_DEVICE, STATE_UNKNOWN)) + module.registerCode(code_tuple, code.get(CONF_CODE)) + + discovery.load_platform(hass, platform, DOMAIN, + {'name': module_name, 'device': device_name}, + config) + + for idx, module_conf in enumerate(sensors): + load_module(CONF_SENSOR, idx, module_conf) + + for idx, module_conf in enumerate(remotes): + load_module(CONF_REMOTE, idx, module_conf) + + def _stop_kira(_event): + for receiver in hass.data[DOMAIN][CONF_SENSOR].values(): + receiver.stop() + _LOGGER.info("Terminated receivers") + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_kira) + + return True diff --git a/homeassistant/components/remote/kira.py b/homeassistant/components/remote/kira.py new file mode 100755 index 00000000000..3e816844a35 --- /dev/null +++ b/homeassistant/components/remote/kira.py @@ -0,0 +1,79 @@ +""" +Support for Keene Electronics IR-IP devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/remote.kira/ +""" +import logging +import functools as ft + +import homeassistant.components.remote as remote +from homeassistant.helpers.entity import Entity + +from homeassistant.const import ( + STATE_UNKNOWN, + CONF_DEVICE, + CONF_NAME) + +DOMAIN = 'kira' + +_LOGGER = logging.getLogger(__name__) + +CONF_REMOTE = "remote" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Kira platform.""" + if discovery_info: + name = discovery_info.get(CONF_NAME) + device = discovery_info.get(CONF_DEVICE) + + kira = hass.data[DOMAIN][CONF_REMOTE][name] + add_devices([KiraRemote(device, kira)]) + return True + + +class KiraRemote(Entity): + """Remote representation used to send commands to a Kira device.""" + + def __init__(self, name, kira): + """Initialize KiraRemote class.""" + _LOGGER.debug("KiraRemote device init started for: %s", name) + self._name = name + self._state = STATE_UNKNOWN + + self._kira = kira + + @property + def name(self): + """Return the Kira device's name.""" + return self._name + + @property + def device_state_attributes(self): + """Add platform specific attributes.""" + return {} + + @property + def is_on(self): + """Return True. Power state doesn't apply to this device.""" + return True + + def update(self): + """No-op.""" + + def send_command(self, **kwargs): + """Send a command to one device.""" + code_tuple = (kwargs.get(remote.ATTR_COMMAND), + kwargs.get(remote.ATTR_DEVICE)) + _LOGGER.info("Sending Command: %s to %s", *code_tuple) + + self._kira.sendCode(code_tuple) + + def async_send_command(self, **kwargs): + """Send a command to a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.loop.run_in_executor( + None, ft.partial(self.send_command, **kwargs)) diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 2023588fcc2..189377c503f 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -1,7 +1,7 @@ # Describes the format for available remote services turn_on: - description: Semds the Power On Command + description: Sends the Power On Command fields: entity_id: @@ -20,7 +20,7 @@ turn_off: example: 'remote.family_room' send_command: - description: Semds a single command to a single device + description: Sends a single command to a single device fields: entity_id: diff --git a/homeassistant/components/sensor/kira.py b/homeassistant/components/sensor/kira.py new file mode 100644 index 00000000000..232e50b85ed --- /dev/null +++ b/homeassistant/components/sensor/kira.py @@ -0,0 +1,79 @@ +"""KIRA interface to receive UDP packets from an IR-IP bridge.""" +# pylint: disable=import-error +import logging + +from homeassistant.const import ( + CONF_DEVICE, + CONF_NAME, + STATE_UNKNOWN) + +from homeassistant.helpers.entity import Entity + +DOMAIN = 'kira' + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:remote' + +CONF_SENSOR = "sensor" + + +# pylint: disable=unused-argument, too-many-function-args +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup Kira sensor.""" + if discovery_info is not None: + name = discovery_info.get(CONF_NAME) + device = discovery_info.get(CONF_DEVICE) + kira = hass.data[DOMAIN][CONF_SENSOR][name] + add_devices_callback([KiraReceiver(device, kira)]) + + +class KiraReceiver(Entity): + """Implementation of a Kira Receiver.""" + + def __init__(self, name, kira): + """Initialize the sensor.""" + self._name = name + self._state = STATE_UNKNOWN + self._device = STATE_UNKNOWN + + kira.registerCallback(self._update_callback) + + def _update_callback(self, code): + code_name, device = code + _LOGGER.info("Kira Code: %s", code_name) + self._state = code_name + self._device = device + self.schedule_update_ha_state() + + @property + def name(self): + """Return the name of the receiver.""" + return self._name + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the receiver.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + attr[CONF_DEVICE] = self._device + return attr + + @property + def should_poll(self) -> bool: + """Entity should not be polled.""" + return False + + @property + def force_update(self) -> bool: + """Kira should force updates. Repeated states have meaning.""" + return True diff --git a/requirements_all.txt b/requirements_all.txt index 6a684cfc310..cbaaaae7bfe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -565,6 +565,9 @@ pyiss==1.0.1 # homeassistant.components.remote.itach pyitachip2ir==0.0.6 +# homeassistant.components.kira +pykira==0.1.1 + # homeassistant.components.sensor.kwb pykwb==0.0.8 diff --git a/tests/components/remote/test_kira.py b/tests/components/remote/test_kira.py new file mode 100644 index 00000000000..144504f8aa2 --- /dev/null +++ b/tests/components/remote/test_kira.py @@ -0,0 +1,57 @@ +"""The tests for Kira sensor platform.""" +import unittest +from unittest.mock import MagicMock + +from homeassistant.components.remote import kira as kira + +from tests.common import get_test_home_assistant + +SERVICE_SEND_COMMAND = 'send_command' + +TEST_CONFIG = {kira.DOMAIN: { + 'devices': [{'host': '127.0.0.1', + 'port': 17324}]}} + +DISCOVERY_INFO = { + 'name': 'kira', + 'device': 'kira' +} + + +class TestKiraSensor(unittest.TestCase): + """Tests the Kira Sensor platform.""" + + # pylint: disable=invalid-name + DEVICES = [] + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.mock_kira = MagicMock() + self.hass.data[kira.DOMAIN] = {kira.CONF_REMOTE: {}} + self.hass.data[kira.DOMAIN][kira.CONF_REMOTE]['kira'] = self.mock_kira + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_service_call(self): + """Test Kira's ability to send commands.""" + kira.setup_platform(self.hass, TEST_CONFIG, self.add_devices, + DISCOVERY_INFO) + assert len(self.DEVICES) == 1 + remote = self.DEVICES[0] + + assert remote.name == 'kira' + + command = "FAKE_COMMAND" + device = "FAKE_DEVICE" + commandTuple = (command, device) + remote.send_command(device=device, command=command) + + self.mock_kira.sendCode.assert_called_with(commandTuple) diff --git a/tests/components/sensor/test_kira.py b/tests/components/sensor/test_kira.py new file mode 100644 index 00000000000..093158cb25c --- /dev/null +++ b/tests/components/sensor/test_kira.py @@ -0,0 +1,59 @@ +"""The tests for Kira sensor platform.""" +import unittest +from unittest.mock import MagicMock + +from homeassistant.components.sensor import kira as kira + +from tests.common import get_test_home_assistant + +TEST_CONFIG = {kira.DOMAIN: { + 'sensors': [{'host': '127.0.0.1', + 'port': 17324}]}} + +DISCOVERY_INFO = { + 'name': 'kira', + 'device': 'kira' +} + + +class TestKiraSensor(unittest.TestCase): + """Tests the Kira Sensor platform.""" + + # pylint: disable=invalid-name + DEVICES = [] + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + mock_kira = MagicMock() + self.hass.data[kira.DOMAIN] = {kira.CONF_SENSOR: {}} + self.hass.data[kira.DOMAIN][kira.CONF_SENSOR]['kira'] = mock_kira + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + # pylint: disable=protected-access + def test_kira_sensor_callback(self): + """Ensure Kira sensor properly updates its attributes from callback.""" + kira.setup_platform(self.hass, TEST_CONFIG, self.add_devices, + DISCOVERY_INFO) + assert len(self.DEVICES) == 1 + sensor = self.DEVICES[0] + + assert sensor.name == 'kira' + + sensor.hass = self.hass + + codeName = 'FAKE_CODE' + deviceName = 'FAKE_DEVICE' + codeTuple = (codeName, deviceName) + sensor._update_callback(codeTuple) + + assert sensor.state == codeName + assert sensor.device_state_attributes == {kira.CONF_DEVICE: deviceName} diff --git a/tests/components/test_kira.py b/tests/components/test_kira.py new file mode 100644 index 00000000000..a80d766c3fd --- /dev/null +++ b/tests/components/test_kira.py @@ -0,0 +1,85 @@ +"""The tests for Home Assistant ffmpeg.""" + +import os +import shutil +import tempfile + +import unittest +from unittest.mock import patch, MagicMock + +import homeassistant.components.kira as kira +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant + +TEST_CONFIG = {kira.DOMAIN: { + 'sensors': [{'name': 'test_sensor', + 'host': '127.0.0.1', + 'port': 34293}, + {'name': 'second_sensor', + 'port': 29847}], + 'remotes': [{'host': '127.0.0.1', + 'port': 34293}, + {'name': 'one_more', + 'host': '127.0.0.1', + 'port': 29847}]}} + +KIRA_CODES = """ +- name: test + code: "K 00FF" +- invalid: not_a_real_code +""" + + +class TestKiraSetup(unittest.TestCase): + """Test class for kira.""" + + # pylint: disable=invalid-name + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + _base_mock = MagicMock() + pykira = _base_mock.pykira + pykira.__file__ = 'test' + self._module_patcher = patch.dict('sys.modules', { + 'pykira': pykira + }) + self._module_patcher.start() + + self.work_dir = tempfile.mkdtemp() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + self._module_patcher.stop() + shutil.rmtree(self.work_dir, ignore_errors=True) + + def test_kira_empty_config(self): + """Kira component should load a default sensor.""" + setup_component(self.hass, kira.DOMAIN, {}) + assert len(self.hass.data[kira.DOMAIN]['sensor']) == 1 + + def test_kira_setup(self): + """Ensure platforms are loaded correctly.""" + setup_component(self.hass, kira.DOMAIN, TEST_CONFIG) + assert len(self.hass.data[kira.DOMAIN]['sensor']) == 2 + assert sorted(self.hass.data[kira.DOMAIN]['sensor'].keys()) == \ + ['kira', 'kira_1'] + assert len(self.hass.data[kira.DOMAIN]['remote']) == 2 + assert sorted(self.hass.data[kira.DOMAIN]['remote'].keys()) == \ + ['kira', 'kira_1'] + + def test_kira_creates_codes(self): + """Kira module should create codes file if missing.""" + code_path = os.path.join(self.work_dir, 'codes.yaml') + kira.load_codes(code_path) + assert os.path.exists(code_path), \ + "Kira component didn't create codes file" + + def test_load_codes(self): + """Kira should ignore invalid codes.""" + code_path = os.path.join(self.work_dir, 'codes.yaml') + with open(code_path, 'w') as code_file: + code_file.write(KIRA_CODES) + res = kira.load_codes(code_path) + assert len(res) == 1, "Expected exactly 1 valid Kira code" diff --git a/tests/testing_config/kira_codes.yaml b/tests/testing_config/kira_codes.yaml new file mode 100644 index 00000000000..e69de29bb2d From cfea4b17e3f111aa1ea02052fdf70c3141dc1b1c Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 12 May 2017 23:06:32 -0700 Subject: [PATCH 094/135] Add tests for zwave network events (#7573) --- tests/components/zwave/test_init.py | 117 ++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 9f4e9b92c68..57fd31be28f 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -320,6 +320,123 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): 'current_temperature'] == 23.5 +@asyncio.coroutine +def test_scene_activated(hass, mock_openzwave): + """Test scene activated event.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_SCENE_EVENT: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + + assert len(mock_receivers) == 1 + + events = [] + + def listener(event): + events.append(event) + + hass.bus.async_listen(const.EVENT_SCENE_ACTIVATED, listener) + + node = MockNode(node_id=11) + scene_id = 123 + hass.async_add_job(mock_receivers[0], node, scene_id) + yield from hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data[ATTR_ENTITY_ID] == "mock_node_11" + assert events[0].data[const.ATTR_OBJECT_ID] == "mock_node_11" + assert events[0].data[const.ATTR_SCENE_ID] == scene_id + + +@asyncio.coroutine +def test_node_event_activated(hass, mock_openzwave): + """Test Node event activated event.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_NODE_EVENT: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + + assert len(mock_receivers) == 1 + + events = [] + + def listener(event): + events.append(event) + + hass.bus.async_listen(const.EVENT_NODE_EVENT, listener) + + node = MockNode(node_id=11) + value = 234 + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data[const.ATTR_OBJECT_ID] == "mock_node_11" + assert events[0].data[const.ATTR_BASIC_LEVEL] == value + + +@asyncio.coroutine +def test_network_ready(hass, mock_openzwave): + """Test Node network ready event.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_ALL_NODES_QUERIED: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + + assert len(mock_receivers) == 1 + + events = [] + + def listener(event): + events.append(event) + + hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE, listener) + + hass.async_add_job(mock_receivers[0]) + yield from hass.async_block_till_done() + + assert len(events) == 1 + + +@asyncio.coroutine +def test_network_complete(hass, mock_openzwave): + """Test Node network complete event.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_AWAKE_NODES_QUERIED: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + + assert len(mock_receivers) == 1 + + events = [] + + def listener(event): + events.append(event) + + hass.bus.async_listen(const.EVENT_NETWORK_READY, listener) + + hass.async_add_job(mock_receivers[0]) + yield from hass.async_block_till_done() + + assert len(events) == 1 + + class TestZWaveDeviceEntityValues(unittest.TestCase): """Tests for the ZWaveDeviceEntityValues helper.""" From cfbbade6d11965db1638d226146e4e4519c6ada6 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Sat, 13 May 2017 14:09:00 -0400 Subject: [PATCH 095/135] Additional Wink lock features (#7445) * Additional Wink lock features --- .../components/alarm_control_panel/wink.py | 6 + .../components/binary_sensor/wink.py | 6 + homeassistant/components/climate/wink.py | 7 + homeassistant/components/cover/wink.py | 7 + homeassistant/components/fan/wink.py | 8 + homeassistant/components/light/wink.py | 6 + homeassistant/components/lock/services.yaml | 56 +++++++ homeassistant/components/lock/wink.py | 158 ++++++++++++++++++ homeassistant/components/scene/wink.py | 7 + homeassistant/components/sensor/wink.py | 6 + homeassistant/components/switch/wink.py | 6 + homeassistant/components/wink.py | 10 +- 12 files changed, 279 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/wink.py b/homeassistant/components/alarm_control_panel/wink.py index 12dca97dd81..a8cad115883 100644 --- a/homeassistant/components/alarm_control_panel/wink.py +++ b/homeassistant/components/alarm_control_panel/wink.py @@ -4,6 +4,7 @@ Interfaces with Wink Cameras. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.wink/ """ +import asyncio import logging import homeassistant.components.alarm_control_panel as alarm @@ -42,6 +43,11 @@ class WinkCameraDevice(WinkDevice, alarm.AlarmControlPanel): """Initialize the Wink alarm.""" super().__init__(wink, hass) + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['alarm_control_panel'].append(self) + @property def state(self): """Return the state of the device.""" diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index 3f77d1d6081..c16c62a5f81 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -4,6 +4,7 @@ Support for Wink binary sensors. For more details about this platform, please refer to the documentation at at https://home-assistant.io/components/binary_sensor.wink/ """ +import asyncio import logging from homeassistant.components.binary_sensor import BinarySensorDevice @@ -101,6 +102,11 @@ class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): else: self.capability = None + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['binary_sensor'].append(self) + @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index 256af2d013c..1be7480a727 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -4,6 +4,8 @@ Support for Wink thermostats. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ """ +import asyncio + from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, @@ -52,6 +54,11 @@ class WinkThermostat(WinkDevice, ClimateDevice): super().__init__(wink, hass) self._config_temp_unit = temp_unit + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['climate'].append(self) + @property def temperature_unit(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 5472180db62..d5908c35ca2 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -4,6 +4,8 @@ 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 from homeassistant.components.wink import WinkDevice, DOMAIN @@ -31,6 +33,11 @@ class WinkCoverDevice(WinkDevice, CoverDevice): """Initialize the cover.""" super().__init__(wink, hass) + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['cover'].append(self) + def close_cover(self): """Close the shade.""" self.wink.set_state(0) diff --git a/homeassistant/components/fan/wink.py b/homeassistant/components/fan/wink.py index e8f5d6fd17a..13f755bcdf3 100644 --- a/homeassistant/components/fan/wink.py +++ b/homeassistant/components/fan/wink.py @@ -4,6 +4,7 @@ Support for Wink fans. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/fan.wink/ """ +import asyncio import logging from homeassistant.components.fan import (FanEntity, SPEED_HIGH, @@ -12,6 +13,8 @@ from homeassistant.components.fan import (FanEntity, SPEED_HIGH, from homeassistant.helpers.entity import ToggleEntity from homeassistant.components.wink import WinkDevice, DOMAIN +DEPENDENCIES = ['wink'] + _LOGGER = logging.getLogger(__name__) SPEED_LOWEST = 'lowest' @@ -34,6 +37,11 @@ class WinkFanDevice(WinkDevice, FanEntity): """Initialize the fan.""" super().__init__(wink, hass) + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['fan'].append(self) + def set_direction(self: ToggleEntity, direction: str) -> None: """Set the direction of the fan.""" self.wink.set_fan_direction(direction) diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 82b7c9f4f8c..1f046a2ec27 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -4,6 +4,7 @@ Support for Wink lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.wink/ """ +import asyncio import colorsys from homeassistant.components.light import ( @@ -38,6 +39,11 @@ class WinkLight(WinkDevice, Light): """Initialize the Wink device.""" super().__init__(wink, hass) + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['light'].append(self) + @property def is_on(self): """Return true if light is on.""" diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 6b12d49302d..df370ca0168 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -55,3 +55,59 @@ unlock: code: description: An optional code to unlock the lock with example: 1234 + +wink_set_lock_vacation_mode: + description: Set vacation mode for all or specified locks. Disables all user codes. + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + enabled: + description: enable or disable. true or false. + example: true + +wink_set_lock_alarm_mode: + description: Set alarm mode for all or specified locks. + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + mode: + description: One of tamper, activity, or forced_entry + example: tamper + +wink_set_lock_alarm_sensitivity: + description: Set alarm sensitivity for all or specified locks. + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + sensitivity: + description: One of low, medium_low, medium, medium_high, high + example: medium + +wink_set_lock_alarm_state: + description: Set alarm state. + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + enabled: + description: enable or disable. true or false. + example: true + +wink_set_lock_beeper_state: + description: Set beeper state. + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + enabled: + description: enable or disable. true or false. + example: true + diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index 9ac5c579ab5..6fbf9edf954 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -4,11 +4,55 @@ Support for Wink locks. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/lock.wink/ """ +import asyncio +import logging +from os import path + +import voluptuous as vol + from homeassistant.components.lock import LockDevice from homeassistant.components.wink import WinkDevice, DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.config import load_yaml_config_file DEPENDENCIES = ['wink'] +_LOGGER = logging.getLogger(__name__) + +SERVICE_SET_VACATION_MODE = 'wink_set_lock_vacation_mode' +SERVICE_SET_ALARM_MODE = 'wink_set_lock_alarm_mode' +SERVICE_SET_ALARM_SENSITIVITY = 'wink_set_lock_alarm_sensitivity' +SERVICE_SET_ALARM_STATE = 'wink_set_lock_alarm_state' +SERVICE_SET_BEEPER_STATE = 'wink_set_lock_beeper_state' + +ATTR_ENABLED = 'enabled' +ATTR_SENSITIVITY = 'sensitivity' +ATTR_MODE = 'mode' + +ALARM_SENSITIVITY_MAP = {"low": 0.2, "medium_low": 0.4, + "medium": 0.6, "medium_high": 0.8, + "high": 1.0} + +ALARM_MODES_MAP = {"tamper": "tamper", + "activity": "alert", + "forced_entry": "forced_entry"} + +SET_ENABLED_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_ENABLED): cv.string, +}) + +SET_SENSITIVITY_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_SENSITIVITY): vol.In(ALARM_SENSITIVITY_MAP) +}) + +SET_ALARM_MODES_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_MODE): vol.In(ALARM_MODES_MAP) +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink platform.""" @@ -19,6 +63,58 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if _id not in hass.data[DOMAIN]['unique_ids']: add_devices([WinkLockDevice(lock, hass)]) + def service_handle(service): + """Handler for services.""" + entity_ids = service.data.get('entity_id') + all_locks = hass.data[DOMAIN]['entities']['lock'] + locks_to_set = [] + if entity_ids is None: + locks_to_set = all_locks + else: + for lock in all_locks: + if lock.entity_id in entity_ids: + locks_to_set.append(lock) + + for lock in locks_to_set: + if service.service == SERVICE_SET_VACATION_MODE: + lock.set_vacation_mode(service.data.get(ATTR_ENABLED)) + elif service.service == SERVICE_SET_ALARM_STATE: + lock.set_alarm_state(service.data.get(ATTR_ENABLED)) + elif service.service == SERVICE_SET_BEEPER_STATE: + lock.set_beeper_state(service.data.get(ATTR_ENABLED)) + elif service.service == SERVICE_SET_ALARM_MODE: + lock.set_alarm_mode(service.data.get(ATTR_MODE)) + elif service.service == SERVICE_SET_ALARM_SENSITIVITY: + lock.set_alarm_sensitivity(service.data.get(ATTR_SENSITIVITY)) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register(DOMAIN, SERVICE_SET_VACATION_MODE, + service_handle, + descriptions.get(SERVICE_SET_VACATION_MODE), + schema=SET_ENABLED_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_ALARM_STATE, + service_handle, + descriptions.get(SERVICE_SET_ALARM_STATE), + schema=SET_ENABLED_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_BEEPER_STATE, + service_handle, + descriptions.get(SERVICE_SET_BEEPER_STATE), + schema=SET_ENABLED_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_ALARM_MODE, + service_handle, + descriptions.get(SERVICE_SET_ALARM_MODE), + schema=SET_ALARM_MODES_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_ALARM_SENSITIVITY, + service_handle, + descriptions.get(SERVICE_SET_ALARM_SENSITIVITY), + schema=SET_SENSITIVITY_SCHEMA) + class WinkLockDevice(WinkDevice, LockDevice): """Representation of a Wink lock.""" @@ -27,6 +123,11 @@ class WinkLockDevice(WinkDevice, LockDevice): """Initialize the lock.""" super().__init__(wink, hass) + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['lock'].append(self) + @property def is_locked(self): """Return true if device is locked.""" @@ -39,3 +140,60 @@ class WinkLockDevice(WinkDevice, LockDevice): def unlock(self, **kwargs): """Unlock the device.""" self.wink.set_state(False) + + def set_alarm_state(self, enabled): + """Set lock's alarm state.""" + self.wink.set_alarm_state(enabled) + + def set_vacation_mode(self, enabled): + """Set lock's vacation mode.""" + self.wink.set_vacation_mode(enabled) + + def set_beeper_state(self, enabled): + """Set lock's beeper mode.""" + self.wink.set_beeper_mode(enabled) + + def set_alarm_sensitivity(self, sensitivity): + """ + Set lock's alarm sensitivity. + + Valid sensitivities: + 0.2, 0.4, 0.6, 0.8, 1.0 + """ + self.wink.set_alarm_sensitivity(sensitivity) + + def set_alarm_mode(self, mode): + """ + Set lock's alarm mode. + + Valid modes: + alert - Beep when lock is locked or unlocked + tamper - 15 sec alarm when lock is disturbed when locked + forced_entry - 3 min alarm when significant force applied + to door when locked. + """ + self.wink.set_alarm_mode(mode) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + super_attrs = super().device_state_attributes + sensitivity = dict_value_to_key(ALARM_SENSITIVITY_MAP, + self.wink.alarm_sensitivity()) + super_attrs['alarm sensitivity'] = sensitivity + super_attrs['vacation mode'] = self.wink.vacation_mode_enabled() + super_attrs['beeper mode'] = self.wink.beeper_enabled() + super_attrs['auto lock'] = self.wink.auto_lock_enabled() + alarm_mode = dict_value_to_key(ALARM_MODES_MAP, + self.wink.alarm_mode()) + super_attrs['alarm mode'] = alarm_mode + super_attrs['alarm enabled'] = self.wink.alarm_enabled() + return super_attrs + + +def dict_value_to_key(dict_map, comp_value): + """Return the key that has the provided value.""" + for key, value in dict_map.items(): + if value == comp_value: + return key + return STATE_UNKNOWN diff --git a/homeassistant/components/scene/wink.py b/homeassistant/components/scene/wink.py index 3906e7b5551..008edf6f131 100644 --- a/homeassistant/components/scene/wink.py +++ b/homeassistant/components/scene/wink.py @@ -4,6 +4,7 @@ Support for Wink scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.wink/ """ +import asyncio import logging from homeassistant.components.scene import Scene @@ -29,6 +30,12 @@ class WinkScene(WinkDevice, Scene): def __init__(self, wink, hass): """Initialize the Wink device.""" super().__init__(wink, hass) + hass.data[DOMAIN]['entities']['scene'].append(self) + + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['scene'].append(self) @property def is_on(self): diff --git a/homeassistant/components/sensor/wink.py b/homeassistant/components/sensor/wink.py index 27cfbd691ad..b8c2b8a6236 100644 --- a/homeassistant/components/sensor/wink.py +++ b/homeassistant/components/sensor/wink.py @@ -4,6 +4,7 @@ Support for Wink sensors. For more details about this platform, please refer to the documentation at at https://home-assistant.io/components/sensor.wink/ """ +import asyncio import logging from homeassistant.const import TEMP_CELSIUS @@ -58,6 +59,11 @@ class WinkSensorDevice(WinkDevice, Entity): else: self._unit_of_measurement = self.wink.unit() + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['sensor'].append(self) + @property def state(self): """Return the state.""" diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index 6783f2201c1..b5feac5fc43 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -4,6 +4,7 @@ Support for Wink switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.wink/ """ +import asyncio from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.helpers.entity import ToggleEntity @@ -40,6 +41,11 @@ class WinkToggleDevice(WinkDevice, ToggleEntity): """Initialize the Wink device.""" super().__init__(wink, hass) + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['switch'].append(self) + @property def is_on(self): """Return true if device is on.""" diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index c22e32b51d4..c33e3b14502 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -75,6 +75,7 @@ def setup(hass, config): hass.data[DOMAIN] = {} hass.data[DOMAIN]['entities'] = [] hass.data[DOMAIN]['unique_ids'] = [] + hass.data[DOMAIN]['entities'] = {} user_agent = config[DOMAIN].get(CONF_USER_AGENT) @@ -154,10 +155,11 @@ def setup(hass, config): def force_update(call): """Force all devices to poll the Wink API.""" _LOGGER.info("Refreshing Wink states from API") - for entity in hass.data[DOMAIN]['entities']: + for entity_list in hass.data[DOMAIN]['entities'].values(): # Throttle the calls to Wink API - time.sleep(1) - entity.schedule_update_ha_state(True) + for entity in entity_list: + time.sleep(1) + entity.schedule_update_ha_state(True) hass.services.register(DOMAIN, SERVICE_REFRESH_STATES, force_update) def pull_new_devices(call): @@ -169,6 +171,7 @@ def setup(hass, config): # Load components for the devices in Wink that we support for component in WINK_COMPONENTS: + hass.data[DOMAIN]['entities'][component] = [] discovery.load_platform(hass, component, DOMAIN, {}, config) return True @@ -183,7 +186,6 @@ class WinkDevice(Entity): self.wink = wink hass.data[DOMAIN]['pubnub'].add_subscription( self.wink.pubnub_channel, self._pubnub_update) - hass.data[DOMAIN]['entities'].append(self) hass.data[DOMAIN]['unique_ids'].append(self.wink.object_id() + self.wink.name()) From 206d02d5313be45e57f9cd16fcae2981befb9aa3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 13 May 2017 16:34:45 -0700 Subject: [PATCH 096/135] Websocket_api: avoid parallel drain (#7576) * Websocket_api: avoid parallel drain * Remove send_message method --- homeassistant/components/websocket_api.py | 103 +++++++++++++--------- 1 file changed, 60 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 466236573c8..fcfd7f404e9 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -30,6 +30,8 @@ DOMAIN = 'websocket_api' URL = '/api/websocket' DEPENDENCIES = 'http', +MAX_PENDING_MSG = 512 + ERR_ID_REUSE = 1 ERR_INVALID_FORMAT = 2 ERR_NOT_FOUND = 3 @@ -211,6 +213,7 @@ class ActiveConnection: self.request = request self.wsock = None self.event_listeners = {} + self.to_write = asyncio.Queue(maxsize=MAX_PENDING_MSG, loop=hass.loop) def debug(self, message1, message2=''): """Print a debug message.""" @@ -220,13 +223,19 @@ class ActiveConnection: """Print an error message.""" _LOGGER.error("WS %s: %s %s", id(self.wsock), message1, message2) - def send_message(self, message): - """Send messages. - - Returns a coroutine object. - """ - self.debug("Sending", message) - return self.wsock.send_json(message, dumps=JSON_DUMP) + @asyncio.coroutine + def _writer(self): + """Write outgoing messages.""" + try: + while True: + message = yield from self.to_write.get() + if message is None: + break + self.debug("Sending", message) + yield from self.wsock.send_json(message, dumps=JSON_DUMP) + except (RuntimeError, asyncio.CancelledError): + # Socket disconnected or cancelled by connection handler + pass @asyncio.coroutine def handle(self): @@ -244,7 +253,8 @@ class ActiveConnection: unsub_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, cancel_connection) - + writer_task = self.hass.async_add_job(self._writer()) + final_message = None self.debug("Connected") msg = None @@ -255,7 +265,7 @@ class ActiveConnection: authenticated = True else: - yield from self.send_message(auth_required_message()) + yield from self.wsock.send_json(auth_required_message()) msg = yield from wsock.receive_json() msg = AUTH_MESSAGE_SCHEMA(msg) @@ -264,14 +274,14 @@ class ActiveConnection: else: self.debug("Invalid password") - yield from self.send_message( + yield from self.wsock.send_json( auth_invalid_message('Invalid password')) if not authenticated: yield from process_wrong_login(self.request) return wsock - yield from self.send_message(auth_ok_message()) + yield from self.wsock.send_json(auth_ok_message()) msg = yield from wsock.receive_json() @@ -283,13 +293,13 @@ class ActiveConnection: cur_id = msg['id'] if cur_id <= last_id: - yield from self.send_message(error_message( + self.to_write.put_nowait(error_message( cur_id, ERR_ID_REUSE, 'Identifier values have to increase.')) else: handler_name = 'handle_{}'.format(msg['type']) - yield from getattr(self, handler_name)(msg) + getattr(self, handler_name)(msg) last_id = cur_id msg = yield from wsock.receive_json() @@ -304,7 +314,7 @@ class ActiveConnection: self.log_error(error_msg) if not authenticated: - yield from self.send_message(auth_invalid_message(error_msg)) + final_message = auth_invalid_message(error_msg) else: if isinstance(msg, dict): @@ -312,8 +322,8 @@ class ActiveConnection: else: iden = None - yield from self.send_message(error_message( - iden, ERR_INVALID_FORMAT, error_msg)) + final_message = error_message( + iden, ERR_INVALID_FORMAT, error_msg) except TypeError as err: if wsock.closed: @@ -331,6 +341,11 @@ class ActiveConnection: except asyncio.CancelledError: self.debug("Connection cancelled by server") + except asyncio.QueueFull: + self.log_error("Client exceeded max pending messages:", + MAX_PENDING_MSG) + writer_task.cancel() + except Exception: # pylint: disable=broad-except error = "Unexpected error inside websocket API. " if msg is not None: @@ -338,6 +353,15 @@ class ActiveConnection: _LOGGER.exception(error) finally: + try: + if final_message is not None: + self.to_write.put_nowait(final_message) + self.to_write.put_nowait(None) + # Make sure all error messages are written before closing + yield from writer_task + except asyncio.QueueFull: + pass + unsub_stop() for unsub in self.event_listeners.values(): @@ -348,9 +372,11 @@ class ActiveConnection: return wsock - @asyncio.coroutine def handle_subscribe_events(self, msg): - """Handle subscribe events command.""" + """Handle subscribe events command. + + Async friendly. + """ msg = SUBSCRIBE_EVENTS_MESSAGE_SCHEMA(msg) @asyncio.coroutine @@ -359,21 +385,17 @@ class ActiveConnection: if event.event_type == EVENT_TIME_CHANGED: return - try: - yield from self.send_message(event_message(msg['id'], event)) - except RuntimeError: - # Socket has been closed. - pass + self.to_write.put_nowait(event_message(msg['id'], event)) self.event_listeners[msg['id']] = self.hass.bus.async_listen( msg['event_type'], forward_events) - return self.send_message(result_message(msg['id'])) + self.to_write.put_nowait(result_message(msg['id'])) def handle_unsubscribe_events(self, msg): """Handle unsubscribe events command. - Returns a coroutine object. + Async friendly. """ msg = UNSUBSCRIBE_EVENTS_MESSAGE_SCHEMA(msg) @@ -381,13 +403,12 @@ class ActiveConnection: if subscription in self.event_listeners: self.event_listeners.pop(subscription)() - return self.send_message(result_message(msg['id'])) + self.to_write.put_nowait(result_message(msg['id'])) else: - return self.send_message(error_message( + self.to_write.put_nowait(error_message( msg['id'], ERR_NOT_FOUND, 'Subscription not found.')) - @asyncio.coroutine def handle_call_service(self, msg): """Handle call service command. @@ -400,57 +421,53 @@ class ActiveConnection: """Call a service and fire complete message.""" yield from self.hass.services.async_call( msg['domain'], msg['service'], msg['service_data'], True) - try: - yield from self.send_message(result_message(msg['id'])) - except RuntimeError: - # Socket has been closed. - pass + self.to_write.put_nowait(result_message(msg['id'])) self.hass.async_add_job(call_service_helper(msg)) def handle_get_states(self, msg): """Handle get states command. - Returns a coroutine object. + Async friendly. """ msg = GET_STATES_MESSAGE_SCHEMA(msg) - return self.send_message(result_message( + self.to_write.put_nowait(result_message( msg['id'], self.hass.states.async_all())) def handle_get_services(self, msg): """Handle get services command. - Returns a coroutine object. + Async friendly. """ msg = GET_SERVICES_MESSAGE_SCHEMA(msg) - return self.send_message(result_message( + self.to_write.put_nowait(result_message( msg['id'], self.hass.services.async_services())) def handle_get_config(self, msg): """Handle get config command. - Returns a coroutine object. + Async friendly. """ msg = GET_CONFIG_MESSAGE_SCHEMA(msg) - return self.send_message(result_message( + self.to_write.put_nowait(result_message( msg['id'], self.hass.config.as_dict())) def handle_get_panels(self, msg): """Handle get panels command. - Returns a coroutine object. + Async friendly. """ msg = GET_PANELS_MESSAGE_SCHEMA(msg) - return self.send_message(result_message( + self.to_write.put_nowait(result_message( msg['id'], self.hass.data[frontend.DATA_PANELS])) def handle_ping(self, msg): """Handle ping command. - Returns a coroutine object. + Async friendly. """ - return self.send_message(pong_message(msg['id'])) + self.to_write.put_nowait(pong_message(msg['id'])) From 352cca1037f1d51b09003b8ce942f97918dcf336 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 13 May 2017 21:25:54 -0700 Subject: [PATCH 097/135] Remove more test requirements (#7574) * No longer require pyunify during tests * No longer require cast during tests * No longer required dependency for tests * No longer require pymochad for tests * Astral is a core dependency * Avoid having to install datadog dependency during tests * CMUS test doesn't test anything * Frontier Silicon doesn't test anything * No longer require mutagen * Update requirements_test_all.txt * Remove stale comment --- requirements_test_all.txt | 21 -- script/gen_requirements_all.py | 10 - tests/common.py | 35 +++ tests/components/device_tracker/test_unifi.py | 268 +++++++++--------- tests/components/media_player/test_cast.py | 13 +- tests/components/media_player/test_cmus.py | 31 -- .../media_player/test_frontier_silicon.py | 42 --- tests/components/switch/test_mochad.py | 20 +- tests/components/test_datadog.py | 23 +- tests/components/tts/test_google.py | 2 + tests/components/tts/test_init.py | 9 + tests/components/tts/test_marytts.py | 2 + tests/components/tts/test_voicerss.py | 2 + tests/components/tts/test_yandextts.py | 2 + 14 files changed, 223 insertions(+), 257 deletions(-) delete mode 100644 tests/components/media_player/test_cmus.py delete mode 100644 tests/components/media_player/test_frontier_silicon.py diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6375e4b232..de96de19214 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -37,18 +37,12 @@ aiohttp_cors==0.5.3 # homeassistant.components.notify.apns apns2==0.1.1 -# homeassistant.components.datadog -datadog==0.15.0 - # homeassistant.components.sensor.dsmr dsmr_parser==0.8 # homeassistant.components.climate.honeywell evohomeclient==0.2.5 -# homeassistant.components.media_player.frontier_silicon -fsapi==0.0.7 - # homeassistant.components.conversation fuzzywuzzy==0.15.0 @@ -75,9 +69,6 @@ libsoundtouch==0.3.0 # homeassistant.components.switch.mfi mficlient==0.3.0 -# homeassistant.components.tts -mutagen==1.37.0 - # homeassistant.components.mqtt paho-mqtt==1.2.3 @@ -94,21 +85,12 @@ pilight==0.1.1 # homeassistant.components.sensor.serial_pm pmsensor==0.4 -# homeassistant.components.media_player.cast -pychromecast==0.8.1 - -# homeassistant.components.media_player.cmus -pycmus==0.1.0 - # homeassistant.components.zwave pydispatcher==2.0.5 # homeassistant.components.litejet pylitejet==0.1 -# homeassistant.components.mochad -pymochad==0.1.1 - # homeassistant.components.alarm_control_panel.nx584 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 @@ -116,9 +98,6 @@ pynx584==0.4 # homeassistant.components.sensor.darksky python-forecastio==1.3.5 -# homeassistant.components.device_tracker.unifi -pyunifi==2.12 - # homeassistant.components.notify.html5 pywebpush==1.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e1f005a3668..614411fbde2 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -38,18 +38,15 @@ TEST_REQUIREMENTS = ( 'uvcclient', 'somecomfort', 'aioautomatic', - 'pyunifi', 'SoCo', 'libsoundtouch', 'rxv', 'apns2', 'sqlalchemy', 'forecastio', - 'astral', 'aiohttp_cors', 'pilight', 'fuzzywuzzy', - 'datadog', 'rflink', 'ring_doorbell', 'sleepyq', @@ -59,21 +56,14 @@ TEST_REQUIREMENTS = ( 'evohomeclient', 'pexpect', 'hbmqtt', - 'pychromecast', - 'pycmus', - 'fsapi', 'paho', - 'jwt', 'dsmr_parser', 'mficlient', 'pmsensor', 'yahoo-finance', - 'pymochad', - 'mutagen', 'ha-ffmpeg', 'gTTS-token', 'pywebpush', - 'pyelliptic', 'PyJWT', ) diff --git a/tests/common.py b/tests/common.py index 9d8f2e33065..5d9494eac81 100644 --- a/tests/common.py +++ b/tests/common.py @@ -495,3 +495,38 @@ def mock_restore_cache(hass, states): "Duplicate entity_id? {}".format(states) hass.state = ha.CoreState.starting mock_component(hass, recorder.DOMAIN) + + +class MockDependency: + """Decorator to mock install a dependency.""" + + def __init__(self, root, *args): + """Initialize decorator.""" + self.root = root + self.submodules = args + + def __call__(self, func): + """Apply decorator.""" + from unittest.mock import MagicMock, patch + + def resolve(mock, path): + """Resolve a mock.""" + if not path: + return mock + + return resolve(getattr(mock, path[0]), path[1:]) + + def run_mocked(*args, **kwargs): + """Run with mocked dependencies.""" + base = MagicMock() + to_mock = { + "{}.{}".format(self.root, tom): resolve(base, tom.split('.')) + for tom in self.submodules + } + to_mock[self.root] = base + + with patch.dict('sys.modules', to_mock): + args = list(args) + [base] + func(*args, **kwargs) + + return run_mocked diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index 37f92e99e22..eea52637241 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -1,157 +1,155 @@ """The tests for the Unifi WAP device tracker platform.""" -import unittest from unittest import mock import urllib -from pyunifi import controller +import pytest import voluptuous as vol -from tests.common import get_test_home_assistant from homeassistant.components.device_tracker import DOMAIN, unifi as unifi from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, CONF_VERIFY_SSL) -class TestUnifiScanner(unittest.TestCase): - """Test the Unifiy platform.""" +@pytest.fixture +def mock_ctrl(): + """Mock pyunifi.""" + module = mock.MagicMock() + with mock.patch.dict('sys.modules', { + 'pyunifi.controller': module.controller, + }): + yield module.controller.Controller - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +@pytest.fixture +def mock_scanner(): + """Mock UnifyScanner.""" + with mock.patch('homeassistant.components.device_tracker' + '.unifi.UnifiScanner') as scanner: + yield scanner - @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') - @mock.patch.object(controller, 'Controller') - def test_config_minimal(self, mock_ctrl, mock_scanner): - """Test the setup with minimal configuration.""" - config = { - DOMAIN: unifi.PLATFORM_SCHEMA({ - CONF_PLATFORM: unifi.DOMAIN, - CONF_USERNAME: 'foo', - CONF_PASSWORD: 'password', - }) + +def test_config_minimal(hass, mock_scanner, mock_ctrl): + """Test the setup with minimal configuration.""" + config = { + DOMAIN: unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, + CONF_USERNAME: 'foo', + CONF_PASSWORD: 'password', + }) + } + result = unifi.get_scanner(hass, config) + assert mock_scanner.return_value == result + assert mock_ctrl.call_count == 1 + assert mock_ctrl.mock_calls[0] == \ + mock.call('localhost', 'foo', 'password', 8443, + version='v4', site_id='default', ssl_verify=True) + + assert mock_scanner.call_count == 1 + assert mock_scanner.call_args == mock.call(mock_ctrl.return_value) + + +def test_config_full(hass, mock_scanner, mock_ctrl): + """Test the setup with full configuration.""" + config = { + DOMAIN: unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, + CONF_USERNAME: 'foo', + CONF_PASSWORD: 'password', + CONF_HOST: 'myhost', + CONF_VERIFY_SSL: False, + 'port': 123, + 'site_id': 'abcdef01', + }) + } + result = unifi.get_scanner(hass, config) + assert mock_scanner.return_value == result + assert mock_ctrl.call_count == 1 + assert mock_ctrl.call_args == \ + mock.call('myhost', 'foo', 'password', 123, + version='v4', site_id='abcdef01', ssl_verify=False) + + assert mock_scanner.call_count == 1 + assert mock_scanner.call_args == mock.call(mock_ctrl.return_value) + + +def test_config_error(): + """Test for configuration errors.""" + with pytest.raises(vol.Invalid): + unifi.PLATFORM_SCHEMA({ + # no username + CONF_PLATFORM: unifi.DOMAIN, + CONF_HOST: 'myhost', + 'port': 123, + }) + with pytest.raises(vol.Invalid): + unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, + CONF_USERNAME: 'foo', + CONF_PASSWORD: 'password', + CONF_HOST: 'myhost', + 'port': 'foo', # bad port! + }) + + +def test_config_controller_failed(hass, mock_ctrl, mock_scanner): + """Test for controller failure.""" + config = { + 'device_tracker': { + CONF_PLATFORM: unifi.DOMAIN, + CONF_USERNAME: 'foo', + CONF_PASSWORD: 'password', } - result = unifi.get_scanner(self.hass, config) - self.assertEqual(mock_scanner.return_value, result) - self.assertEqual(mock_ctrl.call_count, 1) - self.assertEqual( - mock_ctrl.call_args, - mock.call('localhost', 'foo', 'password', 8443, - version='v4', site_id='default', ssl_verify=True) - ) - self.assertEqual(mock_scanner.call_count, 1) - self.assertEqual( - mock_scanner.call_args, - mock.call(mock_ctrl.return_value) - ) + } + mock_ctrl.side_effect = urllib.error.HTTPError( + '/', 500, 'foo', {}, None) + result = unifi.get_scanner(hass, config) + assert result is False - @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') - @mock.patch.object(controller, 'Controller') - def test_config_full(self, mock_ctrl, mock_scanner): - """Test the setup with full configuration.""" - config = { - DOMAIN: unifi.PLATFORM_SCHEMA({ - CONF_PLATFORM: unifi.DOMAIN, - CONF_USERNAME: 'foo', - CONF_PASSWORD: 'password', - CONF_HOST: 'myhost', - CONF_VERIFY_SSL: False, - 'port': 123, - 'site_id': 'abcdef01', - }) - } - result = unifi.get_scanner(self.hass, config) - self.assertEqual(mock_scanner.return_value, result) - self.assertEqual(mock_ctrl.call_count, 1) - self.assertEqual( - mock_ctrl.call_args, - mock.call('myhost', 'foo', 'password', 123, - version='v4', site_id='abcdef01', ssl_verify=False) - ) - self.assertEqual(mock_scanner.call_count, 1) - self.assertEqual( - mock_scanner.call_args, - mock.call(mock_ctrl.return_value) - ) - def test_config_error(self): - """Test for configuration errors.""" - with self.assertRaises(vol.Invalid): - unifi.PLATFORM_SCHEMA({ - # no username - CONF_PLATFORM: unifi.DOMAIN, - CONF_HOST: 'myhost', - 'port': 123, - }) - with self.assertRaises(vol.Invalid): - unifi.PLATFORM_SCHEMA({ - CONF_PLATFORM: unifi.DOMAIN, - CONF_USERNAME: 'foo', - CONF_PASSWORD: 'password', - CONF_HOST: 'myhost', - 'port': 'foo', # bad port! - }) +def test_scanner_update(): + """Test the scanner update.""" + ctrl = mock.MagicMock() + fake_clients = [ + {'mac': '123'}, + {'mac': '234'}, + ] + ctrl.get_clients.return_value = fake_clients + unifi.UnifiScanner(ctrl) + assert ctrl.get_clients.call_count == 1 + assert ctrl.get_clients.call_args == mock.call() - @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') - @mock.patch.object(controller, 'Controller') - def test_config_controller_failed(self, mock_ctrl, mock_scanner): - """Test for controller failure.""" - config = { - 'device_tracker': { - CONF_PLATFORM: unifi.DOMAIN, - CONF_USERNAME: 'foo', - CONF_PASSWORD: 'password', - } - } - mock_ctrl.side_effect = urllib.error.HTTPError( - '/', 500, 'foo', {}, None) - result = unifi.get_scanner(self.hass, config) - self.assertFalse(result) - def test_scanner_update(self): # pylint: disable=no-self-use - """Test the scanner update.""" - ctrl = mock.MagicMock() - fake_clients = [ - {'mac': '123'}, - {'mac': '234'}, - ] - ctrl.get_clients.return_value = fake_clients - unifi.UnifiScanner(ctrl) - self.assertEqual(ctrl.get_clients.call_count, 1) - self.assertEqual(ctrl.get_clients.call_args, mock.call()) +def test_scanner_update_error(): + """Test the scanner update for error.""" + ctrl = mock.MagicMock() + ctrl.get_clients.side_effect = urllib.error.HTTPError( + '/', 500, 'foo', {}, None) + unifi.UnifiScanner(ctrl) - def test_scanner_update_error(self): # pylint: disable=no-self-use - """Test the scanner update for error.""" - ctrl = mock.MagicMock() - ctrl.get_clients.side_effect = urllib.error.HTTPError( - '/', 500, 'foo', {}, None) - unifi.UnifiScanner(ctrl) - def test_scan_devices(self): - """Test the scanning for devices.""" - ctrl = mock.MagicMock() - fake_clients = [ - {'mac': '123'}, - {'mac': '234'}, - ] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl) - self.assertEqual(set(['123', '234']), set(scanner.scan_devices())) +def test_scan_devices(): + """Test the scanning for devices.""" + ctrl = mock.MagicMock() + fake_clients = [ + {'mac': '123'}, + {'mac': '234'}, + ] + ctrl.get_clients.return_value = fake_clients + scanner = unifi.UnifiScanner(ctrl) + assert set(scanner.scan_devices()) == set(['123', '234']) - def test_get_device_name(self): - """Test the getting of device names.""" - ctrl = mock.MagicMock() - fake_clients = [ - {'mac': '123', 'hostname': 'foobar'}, - {'mac': '234', 'name': 'Nice Name'}, - {'mac': '456'}, - ] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl) - self.assertEqual('foobar', scanner.get_device_name('123')) - self.assertEqual('Nice Name', scanner.get_device_name('234')) - self.assertEqual(None, scanner.get_device_name('456')) - self.assertEqual(None, scanner.get_device_name('unknown')) + +def test_get_device_name(): + """Test the getting of device names.""" + ctrl = mock.MagicMock() + fake_clients = [ + {'mac': '123', 'hostname': 'foobar'}, + {'mac': '234', 'name': 'Nice Name'}, + {'mac': '456'}, + ] + ctrl.get_clients.return_value = fake_clients + scanner = unifi.UnifiScanner(ctrl) + assert scanner.get_device_name('123') == 'foobar' + assert scanner.get_device_name('234') == 'Nice Name' + assert scanner.get_device_name('456') is None + assert scanner.get_device_name('unknown') is None diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index c29d41cc590..4ac66702d06 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -1,11 +1,22 @@ """The tests for the Cast Media player platform.""" # pylint: disable=protected-access import unittest -from unittest.mock import patch +from unittest.mock import patch, MagicMock + +import pytest from homeassistant.components.media_player import cast +@pytest.fixture(autouse=True) +def cast_mock(): + """Mock pychromecast.""" + with patch.dict('sys.modules', { + 'pychromecast': MagicMock(), + }): + yield + + class FakeChromeCast(object): """A fake Chrome Cast.""" diff --git a/tests/components/media_player/test_cmus.py b/tests/components/media_player/test_cmus.py deleted file mode 100644 index 24322b5bce0..00000000000 --- a/tests/components/media_player/test_cmus.py +++ /dev/null @@ -1,31 +0,0 @@ -"""The tests for the Demo Media player platform.""" -import unittest -from unittest import mock - -from homeassistant.components.media_player import cmus -from homeassistant import const - -from tests.common import get_test_home_assistant - -entity_id = 'media_player.cmus' - - -class TestCmusMediaPlayer(unittest.TestCase): - """Test the media_player module.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - @mock.patch('homeassistant.components.media_player.cmus.CmusDevice') - def test_password_required_with_host(self, cmus_mock): - """Test that a password is required when specifying a remote host.""" - fake_config = { - const.CONF_HOST: 'a_real_hostname', - } - self.assertFalse( - cmus.setup_platform(self.hass, fake_config, mock.MagicMock())) diff --git a/tests/components/media_player/test_frontier_silicon.py b/tests/components/media_player/test_frontier_silicon.py deleted file mode 100644 index a2c3223cd9c..00000000000 --- a/tests/components/media_player/test_frontier_silicon.py +++ /dev/null @@ -1,42 +0,0 @@ -"""The tests for the Demo Media player platform.""" -import unittest -from unittest import mock - -import logging - -from homeassistant.components.media_player.frontier_silicon import FSAPIDevice -from homeassistant.components.media_player import frontier_silicon -from homeassistant import const - -from tests.common import get_test_home_assistant - -_LOGGER = logging.getLogger(__name__) - - -class TestFrontierSiliconMediaPlayer(unittest.TestCase): - """Test the media_player module.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - def test_host_required_with_host(self): - """Test that a host with a valid url is set when using a conf.""" - fake_config = { - const.CONF_HOST: 'host_ip', - } - result = frontier_silicon.setup_platform(self.hass, - fake_config, mock.MagicMock()) - - self.assertTrue(result) - - def test_invalid_host(self): - """Test that a host with a valid url is set when using a conf.""" - import requests - - fsapi = FSAPIDevice('INVALID_URL', '1234') - self.assertRaises(requests.exceptions.MissingSchema, fsapi.update) diff --git a/tests/components/switch/test_mochad.py b/tests/components/switch/test_mochad.py index fad5b424399..0851bfbc324 100644 --- a/tests/components/switch/test_mochad.py +++ b/tests/components/switch/test_mochad.py @@ -2,6 +2,8 @@ import unittest import unittest.mock as mock +import pytest + from homeassistant.setup import setup_component from homeassistant.components import switch from homeassistant.components.switch import mochad @@ -9,6 +11,15 @@ from homeassistant.components.switch import mochad from tests.common import get_test_home_assistant +@pytest.fixture(autouse=True) +def pymochad_mock(): + """Mock pymochad.""" + with mock.patch.dict('sys.modules', { + 'pymochad': mock.MagicMock(), + }): + yield + + class TestMochadSwitchSetup(unittest.TestCase): """Test the mochad switch.""" @@ -18,17 +29,14 @@ class TestMochadSwitchSetup(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" - super(TestMochadSwitchSetup, self).setUp() self.hass = get_test_home_assistant() def tearDown(self): """Stop everyhing that was started.""" self.hass.stop() - super(TestMochadSwitchSetup, self).tearDown() - @mock.patch('pymochad.controller.PyMochad') @mock.patch('homeassistant.components.switch.mochad.MochadSwitch') - def test_setup_adds_proper_devices(self, mock_switch, mock_client): + def test_setup_adds_proper_devices(self, mock_switch): """Test if setup adds devices.""" good_config = { 'mochad': {}, @@ -50,12 +58,8 @@ class TestMochadSwitch(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" - super(TestMochadSwitch, self).setUp() self.hass = get_test_home_assistant() controller_mock = mock.MagicMock() - device_patch = mock.patch('pymochad.device.Device') - device_patch.start() - self.addCleanup(device_patch.stop) dev_dict = {'address': 'a1', 'name': 'fake_switch'} self.switch = mochad.MochadSwitch(self.hass, controller_mock, dev_dict) diff --git a/tests/components/test_datadog.py b/tests/components/test_datadog.py index 7e051161fc3..f1820c4d250 100644 --- a/tests/components/test_datadog.py +++ b/tests/components/test_datadog.py @@ -12,7 +12,8 @@ from homeassistant.setup import setup_component import homeassistant.components.datadog as datadog import homeassistant.core as ha -from tests.common import (assert_setup_component, get_test_home_assistant) +from tests.common import (assert_setup_component, get_test_home_assistant, + MockDependency) class TestDatadog(unittest.TestCase): @@ -35,10 +36,11 @@ class TestDatadog(unittest.TestCase): } }) - @mock.patch('datadog.initialize') - def test_datadog_setup_full(self, mock_connection): + @MockDependency('datadog', 'beer') + def test_datadog_setup_full(self, mock_datadog): """Test setup with all data.""" self.hass.bus.listen = mock.MagicMock() + mock_connection = mock_datadog.initialize assert setup_component(self.hass, datadog.DOMAIN, { datadog.DOMAIN: { @@ -61,10 +63,11 @@ class TestDatadog(unittest.TestCase): self.assertEqual(EVENT_STATE_CHANGED, self.hass.bus.listen.call_args_list[1][0][0]) - @mock.patch('datadog.initialize') - def test_datadog_setup_defaults(self, mock_connection): + @MockDependency('datadog') + def test_datadog_setup_defaults(self, mock_datadog): """Test setup with defaults.""" self.hass.bus.listen = mock.MagicMock() + mock_connection = mock_datadog.initialize assert setup_component(self.hass, datadog.DOMAIN, { datadog.DOMAIN: { @@ -81,10 +84,11 @@ class TestDatadog(unittest.TestCase): ) self.assertTrue(self.hass.bus.listen.called) - @mock.patch('datadog.statsd') - def test_logbook_entry(self, mock_client): + @MockDependency('datadog') + def test_logbook_entry(self, mock_datadog): """Test event listener.""" self.hass.bus.listen = mock.MagicMock() + mock_client = mock_datadog.statsd assert setup_component(self.hass, datadog.DOMAIN, { datadog.DOMAIN: { @@ -119,10 +123,11 @@ class TestDatadog(unittest.TestCase): mock_client.event.reset_mock() - @mock.patch('datadog.statsd') - def test_state_changed(self, mock_client): + @MockDependency('datadog') + def test_state_changed(self, mock_datadog): """Test event listener.""" self.hass.bus.listen = mock.MagicMock() + mock_client = mock_datadog.statsd assert setup_component(self.hass, datadog.DOMAIN, { datadog.DOMAIN: { diff --git a/tests/components/tts/test_google.py b/tests/components/tts/test_google.py index 9f7cc9e9d50..a68aeef80e3 100644 --- a/tests/components/tts/test_google.py +++ b/tests/components/tts/test_google.py @@ -12,6 +12,8 @@ from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) +from .test_init import mutagen_mock # noqa + class TestTTSGooglePlatform(object): """Test the Google speech component.""" diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index d43dcda8baf..7a15ed28f97 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -4,6 +4,7 @@ import os import shutil from unittest.mock import patch, PropertyMock +import pytest import requests import homeassistant.components.http as http @@ -19,6 +20,14 @@ from tests.common import ( mock_service) +@pytest.fixture(autouse=True) +def mutagen_mock(): + """Mock writing tags.""" + with patch('homeassistant.components.tts.SpeechManager.write_tags', + side_effect=lambda *args: args[1]): + yield + + class TestTTS(object): """Test the Google speech component.""" diff --git a/tests/components/tts/test_marytts.py b/tests/components/tts/test_marytts.py index 29e1a635462..b55236c5e8e 100644 --- a/tests/components/tts/test_marytts.py +++ b/tests/components/tts/test_marytts.py @@ -11,6 +11,8 @@ from homeassistant.components.media_player import ( from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) +from .test_init import mutagen_mock # noqa + class TestTTSMaryTTSPlatform(object): """Test the speech component.""" diff --git a/tests/components/tts/test_voicerss.py b/tests/components/tts/test_voicerss.py index 79629df6d82..2abdc0e69ff 100644 --- a/tests/components/tts/test_voicerss.py +++ b/tests/components/tts/test_voicerss.py @@ -11,6 +11,8 @@ from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) +from .test_init import mutagen_mock # noqa + class TestTTSVoiceRSSPlatform(object): """Test the voicerss speech component.""" diff --git a/tests/components/tts/test_yandextts.py b/tests/components/tts/test_yandextts.py index b7724d7d913..1ed92f34ebe 100644 --- a/tests/components/tts/test_yandextts.py +++ b/tests/components/tts/test_yandextts.py @@ -10,6 +10,8 @@ from homeassistant.components.media_player import ( from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) +from .test_init import mutagen_mock # noqa + class TestTTSYandexPlatform(object): """Test the speech component.""" From e1a4d51fa2f0afa323a358e4f3a0ac9c65954811 Mon Sep 17 00:00:00 2001 From: Matt N Date: Sat, 13 May 2017 23:09:44 -0700 Subject: [PATCH 098/135] camera.zoneminder: Handle old versions of zoneminder (#7589) --- homeassistant/components/camera/zoneminder.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/camera/zoneminder.py b/homeassistant/components/camera/zoneminder.py index 5410833761b..a98e3ef066f 100644 --- a/homeassistant/components/camera/zoneminder.py +++ b/homeassistant/components/camera/zoneminder.py @@ -107,12 +107,7 @@ class ZoneMinderCamera(MjpegCamera): self._monitor_id) return - if not status_response.get("success", False): - _LOGGER.warning("Alarm status API call failed for monitor %i", - self._monitor_id) - return - - self._is_recording = status_response['status'] == ZM_STATE_ALARM + self._is_recording = status_response.get('status') == ZM_STATE_ALARM @property def is_recording(self): From 6d245c43fc324940f563a3724eff06ef7ae5adbe Mon Sep 17 00:00:00 2001 From: Marc Egli Date: Mon, 15 May 2017 08:21:39 +0200 Subject: [PATCH 099/135] Pass additional arguments to tox in test_docker (#7591) --- script/test_docker | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/test_docker b/script/test_docker index 6d17492f703..9f3bbb4be07 100755 --- a/script/test_docker +++ b/script/test_docker @@ -1,5 +1,8 @@ #!/bin/sh # Executes the tests with tox in a docker container. +# Every argment is passed to tox to allow running only a subset of tests. +# The following example will only run media_player tests: +# ./test_docker -- tests/components/media_player/ # Stop on errors set -e @@ -10,4 +13,4 @@ docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.dev . docker run --rm \ -v `pwd`/.tox/:/usr/src/app/.tox/ \ -t -i home-assistant-test \ - tox -e py36 + tox -e py36 ${@:2} From 36d7fe72eb1261c5e7f6b2d3db958d1096a6ad04 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 15 May 2017 00:10:06 -0700 Subject: [PATCH 100/135] Fix websocket api reaching queue (#7590) * Fix websocket api reaching queue * Fix outside task message sending * Fix Py34 tests --- homeassistant/components/websocket_api.py | 98 ++++++++++++++--------- tests/components/test_websocket_api.py | 19 +++++ 2 files changed, 79 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index fcfd7f404e9..6566a20814b 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/developers/websocket_api/ """ import asyncio +from contextlib import suppress from functools import partial import json import logging @@ -201,19 +202,20 @@ class WebsocketAPIView(HomeAssistantView): def get(self, request): """Handle an incoming websocket connection.""" # pylint: disable=no-self-use - return ActiveConnection(request.app['hass'], request).handle() + return ActiveConnection(request.app['hass']).handle(request) class ActiveConnection: """Handle an active websocket client connection.""" - def __init__(self, hass, request): + def __init__(self, hass): """Initialize an active connection.""" self.hass = hass - self.request = request self.wsock = None self.event_listeners = {} self.to_write = asyncio.Queue(maxsize=MAX_PENDING_MSG, loop=hass.loop) + self._handle_task = None + self._writer_task = None def debug(self, message1, message2=''): """Print a debug message.""" @@ -226,42 +228,60 @@ class ActiveConnection: @asyncio.coroutine def _writer(self): """Write outgoing messages.""" - try: - while True: + # Exceptions if Socket disconnected or cancelled by connection handler + with suppress(RuntimeError, asyncio.CancelledError): + while not self.wsock.closed: message = yield from self.to_write.get() if message is None: break self.debug("Sending", message) yield from self.wsock.send_json(message, dumps=JSON_DUMP) - except (RuntimeError, asyncio.CancelledError): - # Socket disconnected or cancelled by connection handler - pass + + @callback + def send_message_outside(self, message): + """Send a message to the client outside of the main task. + + Closes connection if the client is not reading the messages. + + Async friendly. + """ + try: + self.to_write.put_nowait(message) + except asyncio.QueueFull: + self.log_error("Client exceeded max pending messages [2]:", + MAX_PENDING_MSG) + self.cancel() + + @callback + def cancel(self): + """Cancel the connection.""" + self._handle_task.cancel() + self._writer_task.cancel() @asyncio.coroutine - def handle(self): + def handle(self, request): """Handle the websocket connection.""" wsock = self.wsock = web.WebSocketResponse() - yield from wsock.prepare(self.request) - - # Set up to cancel this connection when Home Assistant shuts down - socket_task = asyncio.Task.current_task(loop=self.hass.loop) - - @callback - def cancel_connection(event): - """Cancel this connection.""" - socket_task.cancel() - - unsub_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, cancel_connection) - writer_task = self.hass.async_add_job(self._writer()) - final_message = None + yield from wsock.prepare(request) self.debug("Connected") + # Get a reference to current task so we can cancel our connection + self._handle_task = asyncio.Task.current_task(loop=self.hass.loop) + + @callback + def handle_hass_stop(event): + """Cancel this connection.""" + self.cancel() + + unsub_stop = self.hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, handle_hass_stop) + self._writer_task = self.hass.async_add_job(self._writer()) + final_message = None msg = None authenticated = False try: - if self.request[KEY_AUTHENTICATED]: + if request[KEY_AUTHENTICATED]: authenticated = True else: @@ -269,7 +289,7 @@ class ActiveConnection: msg = yield from wsock.receive_json() msg = AUTH_MESSAGE_SCHEMA(msg) - if validate_password(self.request, msg['api_password']): + if validate_password(request, msg['api_password']): authenticated = True else: @@ -278,13 +298,14 @@ class ActiveConnection: auth_invalid_message('Invalid password')) if not authenticated: - yield from process_wrong_login(self.request) + yield from process_wrong_login(request) return wsock yield from self.wsock.send_json(auth_ok_message()) - msg = yield from wsock.receive_json() + # ---------- AUTH PHASE OVER ---------- + msg = yield from wsock.receive_json() last_id = 0 while msg: @@ -337,14 +358,15 @@ class ActiveConnection: if value: msg += ': {}'.format(value) self.log_error(msg) + self._writer_task.cancel() except asyncio.CancelledError: self.debug("Connection cancelled by server") except asyncio.QueueFull: - self.log_error("Client exceeded max pending messages:", + self.log_error("Client exceeded max pending messages [1]:", MAX_PENDING_MSG) - writer_task.cancel() + self._writer_task.cancel() except Exception: # pylint: disable=broad-except error = "Unexpected error inside websocket API. " @@ -353,19 +375,19 @@ class ActiveConnection: _LOGGER.exception(error) finally: + unsub_stop() + + for unsub in self.event_listeners.values(): + unsub() + try: if final_message is not None: self.to_write.put_nowait(final_message) self.to_write.put_nowait(None) # Make sure all error messages are written before closing - yield from writer_task + yield from self._writer_task except asyncio.QueueFull: - pass - - unsub_stop() - - for unsub in self.event_listeners.values(): - unsub() + self._writer_task.cancel() yield from wsock.close() self.debug("Closed connection") @@ -385,7 +407,7 @@ class ActiveConnection: if event.event_type == EVENT_TIME_CHANGED: return - self.to_write.put_nowait(event_message(msg['id'], event)) + self.send_message_outside(event_message(msg['id'], event)) self.event_listeners[msg['id']] = self.hass.bus.async_listen( msg['event_type'], forward_events) @@ -421,7 +443,7 @@ class ActiveConnection: """Call a service and fire complete message.""" yield from self.hass.services.async_call( msg['domain'], msg['service'], msg['service_data'], True) - self.to_write.put_nowait(result_message(msg['id'])) + self.send_message_outside(result_message(msg['id'])) self.hass.async_add_job(call_service_helper(msg)) diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 658a5e0be53..9ca429f6f52 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -50,6 +50,13 @@ def no_auth_websocket_client(hass, loop, test_client): loop.run_until_complete(ws.close()) +@pytest.fixture +def mock_low_queue(): + """Mock a low queue.""" + with patch.object(wapi, 'MAX_PENDING_MSG', 5): + yield + + @asyncio.coroutine def test_auth_via_msg(no_auth_websocket_client): """Test authenticating.""" @@ -304,3 +311,15 @@ def test_ping(websocket_client): msg = yield from websocket_client.receive_json() assert msg['id'] == 5 assert msg['type'] == wapi.TYPE_PONG + + +@asyncio.coroutine +def test_pending_msg_overflow(hass, mock_low_queue, websocket_client): + """Test get_panels command.""" + for idx in range(10): + websocket_client.send_json({ + 'id': idx + 1, + 'type': wapi.TYPE_PING, + }) + msg = yield from websocket_client.receive() + assert msg.type == WSMsgType.close From d0304198dee798ded5751938002b25ce6bcbe9e7 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Mon, 15 May 2017 09:23:57 +0200 Subject: [PATCH 101/135] SMTP notify enhancements: full HTML emails and custom `product_name` in email headers (#7533) * SMTP notify enhancements: HTML emails and customization options - Send full HTML emails, with or without inline attached images. - Custom `timeout`. - Custom `product_name` identifier for the `FROM` and `X-MAILER` email headers. - New HTML email test * `sender_name` instead of product_name - Change `sender_name` instead of `product_name`. - No changes in `X-Mailer` header. - `From` header as always unless you define the new `sender_name` parameter. --- homeassistant/components/notify/smtp.py | 55 +++++++++++++++++++++---- tests/components/notify/test_smtp.py | 26 +++++++++++- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 1a2e1bf5b4e..d66d024e111 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -9,9 +9,9 @@ import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.image import MIMEImage -import email.utils from email.mime.application import MIMEApplication - +import email.utils +import os import voluptuous as vol from homeassistant.components.notify import ( @@ -26,10 +26,12 @@ import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) ATTR_IMAGES = 'images' # optional embedded image file attachments +ATTR_HTML = 'html' CONF_STARTTLS = 'starttls' CONF_DEBUG = 'debug' CONF_SERVER = 'server' +CONF_SENDER_NAME = 'sender_name' DEFAULT_HOST = 'localhost' DEFAULT_PORT = 25 @@ -47,6 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_STARTTLS, default=DEFAULT_STARTTLS): cv.boolean, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SENDER_NAME): cv.string, vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean, }) @@ -62,6 +65,7 @@ def get_service(hass, config, discovery_info=None): config.get(CONF_USERNAME), config.get(CONF_PASSWORD), config.get(CONF_RECIPIENT), + config.get(CONF_SENDER_NAME), config.get(CONF_DEBUG)) if mail_service.connection_is_valid(): @@ -74,7 +78,7 @@ class MailNotificationService(BaseNotificationService): """Implement the notification service for E-Mail messages.""" def __init__(self, server, port, timeout, sender, starttls, username, - password, recipients, debug): + password, recipients, sender_name, debug): """Initialize the service.""" self._server = server self._port = port @@ -84,6 +88,8 @@ class MailNotificationService(BaseNotificationService): self.username = username self.password = password self.recipients = recipients + self._sender_name = sender_name + self._timeout = timeout self.debug = debug self.tries = 2 @@ -128,19 +134,28 @@ class MailNotificationService(BaseNotificationService): Build and send a message to a user. Will send plain text normally, or will build a multipart HTML message - with inline image attachments if images config is defined. + with inline image attachments if images config is defined, or will + build a multipart HTML if html config is defined. """ subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) if data: - msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES)) + if ATTR_HTML in data: + msg = _build_html_msg(message, data[ATTR_HTML], + images=data.get(ATTR_IMAGES)) + else: + msg = _build_multipart_msg(message, + images=data.get(ATTR_IMAGES)) else: msg = _build_text_msg(message) msg['Subject'] = subject msg['To'] = ','.join(self.recipients) - msg['From'] = self._sender + if self._sender_name: + msg['From'] = '{} <{}>'.format(self._sender_name, self._sender) + else: + msg['From'] = self._sender msg['X-Mailer'] = 'HomeAssistant' msg['Date'] = email.utils.format_datetime(dt_util.now()) msg['Message-Id'] = email.utils.make_msgid() @@ -155,12 +170,16 @@ class MailNotificationService(BaseNotificationService): mail.sendmail(self._sender, self.recipients, msg.as_string()) break + except smtplib.SMTPServerDisconnected: + _LOGGER.warning( + "SMTPServerDisconnected sending mail: retrying connection") + mail.quit() + mail = self.connect() except smtplib.SMTPException: _LOGGER.warning( "SMTPException sending mail: retrying connection") mail.quit() mail = self.connect() - mail.quit() @@ -204,3 +223,25 @@ def _build_multipart_msg(message, images): body_html = MIMEText(''.join(body_text), 'html') msg_alt.attach(body_html) return msg + + +def _build_html_msg(text, html, images): + """Build Multipart message with in-line images and rich html (UTF-8).""" + _LOGGER.debug("Building html rich email") + msg = MIMEMultipart('related') + alternative = MIMEMultipart('alternative') + alternative.attach(MIMEText(text, _charset='utf-8')) + alternative.attach(MIMEText(html, ATTR_HTML, _charset='utf-8')) + msg.attach(alternative) + + for atch_num, atch_name in enumerate(images): + name = os.path.basename(atch_name) + try: + with open(atch_name, 'rb') as attachment_file: + attachment = MIMEImage(attachment_file.read(), filename=name) + msg.attach(attachment) + attachment.add_header('Content-ID', '<{}>'.format(name)) + except FileNotFoundError: + _LOGGER.warning('Attachment %s [#%s] not found. Skipping', + atch_name, atch_num) + return msg diff --git a/tests/components/notify/test_smtp.py b/tests/components/notify/test_smtp.py index 016c6a5d1f4..127eecae2b7 100644 --- a/tests/components/notify/test_smtp.py +++ b/tests/components/notify/test_smtp.py @@ -23,7 +23,8 @@ class TestNotifySmtp(unittest.TestCase): self.hass = get_test_home_assistant() self.mailer = MockSMTP('localhost', 25, 5, 'test@test.com', 1, 'testuser', 'testpass', - ['recip1@example.com', 'testrecip@test.com'], 0) + ['recip1@example.com', 'testrecip@test.com'], + 'HomeAssistant', 0) def tearDown(self): # pylint: disable=invalid-name """"Stop down everything that was started.""" @@ -38,7 +39,7 @@ class TestNotifySmtp(unittest.TestCase): 'Content-Transfer-Encoding: 7bit\n' 'Subject: Home Assistant\n' 'To: recip1@example.com,testrecip@test.com\n' - 'From: test@test.com\n' + 'From: HomeAssistant \n' 'X-Mailer: HomeAssistant\n' 'Date: [^\n]+\n' 'Message-Id: <[^@]+@[^>]+>\n' @@ -52,3 +53,24 @@ class TestNotifySmtp(unittest.TestCase): msg = self.mailer.send_message('Test msg', data={'images': ['test.jpg']}) self.assertTrue('Content-Type: multipart/related' in msg) + + @patch('email.utils.make_msgid', return_value='') + def test_html_email(self, mock_make_msgid): + """Test build of html email behavior.""" + html = ''' + + + + +
+

Intruder alert at apartment!!

+
+
+ test.jpg +
+ + ''' + msg = self.mailer.send_message('Test msg', + data={'html': html, + 'images': ['test.jpg']}) + self.assertTrue('Content-Type: multipart/related' in msg) From e2e58e6acc5bb34c2a5d30e36ff7a34a926888dd Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Mon, 15 May 2017 03:34:30 -0400 Subject: [PATCH 102/135] Automation State Change For timer attribute fix (#7584) --- homeassistant/components/automation/state.py | 4 + tests/components/automation/test_state.py | 79 ++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 576e9e60186..9c12a37f9b8 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -79,6 +79,10 @@ def async_trigger(hass, config, action): call_action() return + # If only state attributes changed, ignore this event + if from_s.last_changed == to_s.last_changed: + return + @callback def state_for_listener(now): """Fire on state changes after a delay and calls action.""" diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index afddaa85b04..d65ffcb4d4f 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -333,6 +333,40 @@ class TestAutomationState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(self.calls)) + def test_if_fires_on_entity_change_with_for_attribute_change(self): + """Test for firing on entity change with for and attribute change.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'to': 'world', + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + utcnow = dt_util.utcnow() + with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: + mock_utcnow.return_value = utcnow + self.hass.states.set('test.entity', 'world') + self.hass.block_till_done() + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.states.set('test.entity', 'world', + attributes={"mock_attr": "attr_change"}) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_with_for(self): """Test for firing on entity change with for.""" assert setup_component(self.hass, automation.DOMAIN, { @@ -393,6 +427,51 @@ class TestAutomationState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) + def test_if_fires_on_for_condition_attribute_change(self): + """Test for firing if contition is on with attribute change.""" + point1 = dt_util.utcnow() + point2 = point1 + timedelta(seconds=4) + point3 = point1 + timedelta(seconds=8) + with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: + mock_utcnow.return_value = point1 + self.hass.states.set('test.entity', 'on') + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'condition': { + 'condition': 'state', + 'entity_id': 'test.entity', + 'state': 'on', + 'for': { + 'seconds': 5 + }, + }, + 'action': {'service': 'test.automation'}, + } + }) + + # not enough time has passed + self.hass.bus.fire('test_event') + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + + # Still not enough time has passed, but an attribute is changed + mock_utcnow.return_value = point2 + self.hass.states.set('test.entity', 'on', + attributes={"mock_attr": "attr_change"}) + self.hass.bus.fire('test_event') + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + + # Enough time has now passed + mock_utcnow.return_value = point3 + self.hass.bus.fire('test_event') + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fails_setup_for_without_time(self): """Test for setup failure if no time is provided.""" with assert_setup_component(0): From 5c4a21efac9cbd14d887610b05c153b5be526465 Mon Sep 17 00:00:00 2001 From: jhemzal Date: Mon, 15 May 2017 09:34:51 +0200 Subject: [PATCH 103/135] Add posibility to specify snmp protocol version (#7564) * snmp sensor protocol version configuration option * fixed lint findings. --- homeassistant/components/sensor/snmp.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 1342f3d2a9e..2ce08f262d7 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -22,11 +22,18 @@ _LOGGER = logging.getLogger(__name__) CONF_BASEOID = 'baseoid' CONF_COMMUNITY = 'community' +CONF_VERSION = 'version' DEFAULT_COMMUNITY = 'public' DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'SNMP' DEFAULT_PORT = '161' +DEFAULT_VERSION = '1' + +SNMP_VERSIONS = { + '1': 0, + '2c': 1 +} MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) @@ -37,6 +44,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): + vol.In(SNMP_VERSIONS), }) @@ -52,10 +61,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): community = config.get(CONF_COMMUNITY) baseoid = config.get(CONF_BASEOID) unit = config.get(CONF_UNIT_OF_MEASUREMENT) + version = config.get(CONF_VERSION) errindication, _, _, _ = next( getCmd(SnmpEngine(), - CommunityData(community, mpModel=0), + CommunityData(community, mpModel=SNMP_VERSIONS[version]), UdpTransportTarget((host, port)), ContextData(), ObjectType(ObjectIdentity(baseoid)))) @@ -64,7 +74,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Please check the details in the configuration file") return False else: - data = SnmpData(host, port, community, baseoid) + data = SnmpData(host, port, community, baseoid, version) add_devices([SnmpSensor(data, name, unit)]) @@ -103,12 +113,13 @@ class SnmpSensor(Entity): class SnmpData(object): """Get the latest data and update the states.""" - def __init__(self, host, port, community, baseoid): + def __init__(self, host, port, community, baseoid, version): """Initialize the data object.""" self._host = host self._port = port self._community = community self._baseoid = baseoid + self._version = SNMP_VERSIONS[version] self.value = None @Throttle(MIN_TIME_BETWEEN_UPDATES) @@ -119,7 +130,7 @@ class SnmpData(object): ObjectType, ObjectIdentity) errindication, errstatus, errindex, restable = next( getCmd(SnmpEngine(), - CommunityData(self._community, mpModel=0), + CommunityData(self._community, mpModel=self._version), UdpTransportTarget((self._host, self._port)), ContextData(), ObjectType(ObjectIdentity(self._baseoid))) From 4da91d6a8b961b422d273bf29aa47da5d30b81bb Mon Sep 17 00:00:00 2001 From: Marc Egli Date: Mon, 15 May 2017 09:42:45 +0200 Subject: [PATCH 104/135] Add sonos alarm clock update service (#7521) * Add sonos alarm clock update service * Add tests and break lines * Fix style errors * Make test work with python<3.6 * Fix last two pylint errors * fix new line to long errors --- .../components/media_player/sonos.py | 45 +++++++++++++++++++ tests/components/media_player/test_sonos.py | 32 +++++++++++++ 2 files changed, 77 insertions(+) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index c27ae16b926..c209fde1679 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -51,6 +51,7 @@ SERVICE_SNAPSHOT = 'sonos_snapshot' SERVICE_RESTORE = 'sonos_restore' SERVICE_SET_TIMER = 'sonos_set_sleep_timer' SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer' +SERVICE_UPDATE_ALARM = 'sonos_update_alarm' DATA_SONOS = 'sonos' @@ -62,6 +63,11 @@ CONF_INTERFACE_ADDR = 'interface_addr' # Service call validation schemas ATTR_SLEEP_TIME = 'sleep_time' +ATTR_ALARM_ID = 'alarm_id' +ATTR_VOLUME = 'volume' +ATTR_ENABLED = 'enabled' +ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones' +ATTR_TIME = 'time' ATTR_MASTER = 'master' ATTR_WITH_GROUP = 'with_group' @@ -90,6 +96,14 @@ SONOS_SET_TIMER_SCHEMA = SONOS_SCHEMA.extend({ vol.All(vol.Coerce(int), vol.Range(min=0, max=86399)) }) +SONOS_UPDATE_ALARM_SCHEMA = SONOS_SCHEMA.extend({ + vol.Required(ATTR_ALARM_ID): cv.positive_int, + vol.Optional(ATTR_TIME): cv.time, + vol.Optional(ATTR_VOLUME): cv.small_float, + vol.Optional(ATTR_ENABLED): cv.boolean, + vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" @@ -166,6 +180,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device.set_sleep_timer(service.data[ATTR_SLEEP_TIME]) elif service.service == SERVICE_CLEAR_TIMER: device.clear_sleep_timer() + elif service.service == SERVICE_UPDATE_ALARM: + device.update_alarm(**service.data) device.schedule_update_ha_state(True) @@ -193,6 +209,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): DOMAIN, SERVICE_CLEAR_TIMER, service_handle, descriptions.get(SERVICE_CLEAR_TIMER), schema=SONOS_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_UPDATE_ALARM, service_handle, + descriptions.get(SERVICE_UPDATE_ALARM), + schema=SONOS_UPDATE_ALARM_SCHEMA) + def _parse_timespan(timespan): """Parse a time-span into number of seconds.""" @@ -1034,6 +1055,30 @@ class SonosDevice(MediaPlayerDevice): """Clear the timer on the player.""" self._player.set_sleep_timer(None) + @soco_error + @soco_coordinator + def update_alarm(self, **data): + """Set the alarm clock on the player.""" + from soco import alarms + a = None + for alarm in alarms.get_alarms(self.soco): + # pylint: disable=protected-access + if alarm._alarm_id == str(data[ATTR_ALARM_ID]): + a = alarm + if a is None: + _LOGGER.warning("did not find alarm with id %s", + data[ATTR_ALARM_ID]) + return + if ATTR_TIME in data: + a.start_time = data[ATTR_TIME] + if ATTR_VOLUME in data: + a.volume = int(data[ATTR_VOLUME] * 100) + if ATTR_ENABLED in data: + a.enabled = data[ATTR_ENABLED] + if ATTR_INCLUDE_LINKED_ZONES in data: + a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] + a.save() + @property def device_state_attributes(self): """Return device specific state attributes.""" diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index ebf92cb4d1a..8c62c6c84e9 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -1,9 +1,11 @@ """The tests for the Demo Media player platform.""" +import datetime import socket import unittest import soco.snapshot from unittest import mock import soco +from soco import alarms from homeassistant.setup import setup_component from homeassistant.components.media_player import sonos, DOMAIN @@ -307,6 +309,36 @@ class TestSonosMediaPlayer(unittest.TestCase): device.set_sleep_timer(None) set_sleep_timerMock.assert_called_once_with(None) + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('soco.alarms.Alarm') + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_update_alarm(self, soco_mock, alarm_mock, *args): + """Ensuring soco methods called for sonos_set_sleep_timer service.""" + sonos.setup_platform(self.hass, {}, fake_add_device, { + 'host': '192.0.2.1' + }) + device = self.hass.data[sonos.DATA_SONOS][-1] + device.hass = self.hass + alarm1 = alarms.Alarm(soco_mock) + alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False, + include_linked_zones=False, volume=100) + with mock.patch('soco.alarms.get_alarms', return_value=[alarm1]): + attrs = { + 'time': datetime.time(12, 00), + 'enabled': True, + 'include_linked_zones': True, + 'volume': 0.30, + } + device.update_alarm(alarm_id=2) + alarm1.save.assert_not_called() + device.update_alarm(alarm_id=1, **attrs) + self.assertEqual(alarm1.enabled, attrs['enabled']) + self.assertEqual(alarm1.start_time, attrs['time']) + self.assertEqual(alarm1.include_linked_zones, + attrs['include_linked_zones']) + self.assertEqual(alarm1.volume, 30) + alarm1.save.assert_called_once_with() + @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch.object(soco.snapshot.Snapshot, 'snapshot') From a1dc35fc750b130615d3c82f0f8c1926ead7a200 Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Mon, 15 May 2017 03:46:43 -0400 Subject: [PATCH 105/135] Fix handling of single user (#7587) --- homeassistant/components/eight_sleep.py | 5 ++++- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index db718aec05e..22647532d9a 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -REQUIREMENTS = ['pyeight==0.0.4'] +REQUIREMENTS = ['pyeight==0.0.5'] _LOGGER = logging.getLogger(__name__) @@ -145,6 +145,9 @@ def async_setup(hass, config): sensors.append('{}_{}'.format(obj.side, sensor)) binary_sensors.append('{}_presence'.format(obj.side)) sensors.append('room_temp') + else: + # No users, cannot continue + return False hass.async_add_job(discovery.async_load_platform( hass, 'sensor', DOMAIN, { diff --git a/requirements_all.txt b/requirements_all.txt index cbaaaae7bfe..55678e55e87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -527,7 +527,7 @@ pydroid-ipcam==0.8 pyebox==0.1.0 # homeassistant.components.eight_sleep -pyeight==0.0.4 +pyeight==0.0.5 # homeassistant.components.media_player.emby pyemby==1.2 From f25347d98d6ae4cc976945b1299e39b6dfe38bff Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 15 May 2017 14:25:46 +0200 Subject: [PATCH 106/135] File sensor (#7569) * Add File sensor * Use None and return * Remove I/O * Use less memory * No traceback if file is empty --- homeassistant/components/sensor/file.py | 98 +++++++++++++++++++++++++ tests/components/sensor/test_file.py | 91 +++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 homeassistant/components/sensor/file.py create mode 100644 tests/components/sensor/test_file.py diff --git a/homeassistant/components/sensor/file.py b/homeassistant/components/sensor/file.py new file mode 100644 index 00000000000..afa305a0fb0 --- /dev/null +++ b/homeassistant/components/sensor/file.py @@ -0,0 +1,98 @@ +""" +Support for sensor value(s) stored in local files. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.file/ +""" +import os +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_VALUE_TEMPLATE, CONF_NAME, CONF_UNIT_OF_MEASUREMENT) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_FILE_PATH = 'file_path' + +DEFAULT_NAME = 'File' + +ICON = 'mdi:file' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILE_PATH): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the file sensor.""" + file_path = config.get(CONF_FILE_PATH) + name = config.get(CONF_NAME) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + value_template = config.get(CONF_VALUE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + + async_add_devices( + [FileSensor(name, file_path, unit, value_template)], True) + + +class FileSensor(Entity): + """Implementation of a file sensor.""" + + def __init__(self, name, file_path, unit_of_measurement, value_template): + """Initialize the file sensor.""" + self._name = name + self._file_path = file_path + self._unit_of_measurement = unit_of_measurement + self._val_tpl = value_template + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest entry from a file and updates the state.""" + try: + with open(self._file_path, 'r', encoding='utf-8') as file_data: + for line in file_data: + data = line + data = data.strip() + except (IndexError, FileNotFoundError, IsADirectoryError, + UnboundLocalError): + _LOGGER.warning("File or data not present at the moment: %s", + os.path.basename(self._file_path)) + return + + if self._val_tpl is not None: + self._state = self._val_tpl.async_render_with_possible_json_value( + data, None) + else: + self._state = data diff --git a/tests/components/sensor/test_file.py b/tests/components/sensor/test_file.py new file mode 100644 index 00000000000..00e8f2ba525 --- /dev/null +++ b/tests/components/sensor/test_file.py @@ -0,0 +1,91 @@ +"""The tests for local file sensor platform.""" +import unittest +from unittest.mock import Mock, patch + +# Using third party package because of a bug reading binary data in Python 3.4 +# https://bugs.python.org/issue23004 +from mock_open import MockOpen + +from homeassistant.setup import setup_component +from homeassistant.const import STATE_UNKNOWN + +from tests.common import get_test_home_assistant + + +class TestFileSensor(unittest.TestCase): + """Test the File sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + @patch('os.path.isfile', Mock(return_value=True)) + @patch('os.access', Mock(return_value=True)) + def test_file_value(self): + """Test the File sensor.""" + config = { + 'sensor': { + 'platform': 'file', + 'name': 'file1', + 'file_path': 'mock.file1', + } + } + + m_open = MockOpen(read_data='43\n45\n21') + with patch('homeassistant.components.sensor.file.open', m_open, + create=True): + assert setup_component(self.hass, 'sensor', config) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.file1') + self.assertEqual(state.state, '21') + + @patch('os.path.isfile', Mock(return_value=True)) + @patch('os.access', Mock(return_value=True)) + def test_file_value_template(self): + """Test the File sensor with JSON entries.""" + config = { + 'sensor': { + 'platform': 'file', + 'name': 'file2', + 'file_path': 'mock.file2', + 'value_template': '{{ value_json.temperature }}', + } + } + + data = '{"temperature": 29, "humidity": 31}\n' \ + '{"temperature": 26, "humidity": 36}' + + m_open = MockOpen(read_data=data) + with patch('homeassistant.components.sensor.file.open', m_open, + create=True): + assert setup_component(self.hass, 'sensor', config) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.file2') + self.assertEqual(state.state, '26') + + @patch('os.path.isfile', Mock(return_value=True)) + @patch('os.access', Mock(return_value=True)) + def test_file_empty(self): + """Test the File sensor with an empty file.""" + config = { + 'sensor': { + 'platform': 'file', + 'name': 'file3', + 'file_path': 'mock.file', + } + } + + m_open = MockOpen(read_data='') + with patch('homeassistant.components.sensor.file.open', m_open, + create=True): + assert setup_component(self.hass, 'sensor', config) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.file3') + self.assertEqual(state.state, STATE_UNKNOWN) From d6081f3dc5b359ecd0a1a2f495b957859f841b29 Mon Sep 17 00:00:00 2001 From: Marc Egli Date: Tue, 16 May 2017 08:13:41 +0200 Subject: [PATCH 107/135] Make miflora monitored_conditions parameter optional (#7598) * Make miflora monitored_conditions parameter optional. * Use default keyword instead. * Use list instead of tuple * Simplify even more --- homeassistant/components/sensor/miflora.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index ac8646bb3c1..063c4e8068e 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -45,7 +45,7 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MAC): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS): + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int, From d5ca6a5aedc8003141a4695b05accb0a15361bf0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 15 May 2017 23:15:06 -0700 Subject: [PATCH 108/135] Force automation ids to always be a string (#7612) --- homeassistant/components/automation/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 7c11f15862f..19d542628bc 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -82,7 +82,8 @@ _TRIGGER_SCHEMA = vol.All( _CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) PLATFORM_SCHEMA = vol.Schema({ - CONF_ID: cv.string, + # str on purpose + CONF_ID: str, CONF_ALIAS: cv.string, vol.Optional(CONF_INITIAL_STATE): cv.boolean, vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, From 641ba014f2f4310833f866ddbc5b1012aa135407 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 15 May 2017 23:17:21 -0700 Subject: [PATCH 109/135] Update frontend --- homeassistant/components/frontend/version.py | 8 ++++---- .../components/frontend/www_static/core.js | 2 +- .../components/frontend/www_static/core.js.gz | Bin 2678 -> 2681 bytes .../frontend/www_static/frontend.html | 2 +- .../frontend/www_static/frontend.html.gz | Bin 140627 -> 140708 bytes .../www_static/home-assistant-polymer | 2 +- .../panels/ha-panel-automation.html | 4 ++-- .../panels/ha-panel-automation.html.gz | Bin 40511 -> 43960 bytes .../www_static/panels/ha-panel-hassio.html | 18 +----------------- .../www_static/panels/ha-panel-hassio.html.gz | Bin 7381 -> 392 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2509 -> 2512 bytes 12 files changed, 11 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index f92bb64ff69..3dea156f6ed 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -2,18 +2,18 @@ FINGERPRINTS = { "compatibility.js": "8e4c44b5f4288cc48ec1ba94a9bec812", - "core.js": "8cc30e2ad9ee3df44fe7a17507099d88", - "frontend.html": "5999c8fac69c503b846672cae75a12b0", + "core.js": "d4a7cb8c80c62b536764e0e81385f6aa", + "frontend.html": "19637e5a62837c8dc0bec1863adc9249", "mdi.html": "f407a5a57addbe93817ee1b244d33fbe", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-automation.html": "cc6fe23a97c1974b9f4165a7692bb280", + "panels/ha-panel-automation.html": "f9a6727e2354224577298fc0f2dadc2e", "panels/ha-panel-config.html": "59d9eb28758b497a4d9b2428f978b9b1", "panels/ha-panel-dev-event.html": "2db9c218065ef0f61d8d08db8093cad2", "panels/ha-panel-dev-info.html": "61610e015a411cfc84edd2c4d489e71d", "panels/ha-panel-dev-service.html": "415552027cb083badeff5f16080410ed", "panels/ha-panel-dev-state.html": "d70314913b8923d750932367b1099750", "panels/ha-panel-dev-template.html": "567fbf86735e1b891e40c2f4060fec9b", - "panels/ha-panel-hassio.html": "41fc94a5dc9247ed7efa112614491c71", + "panels/ha-panel-hassio.html": "9474ba65077371622f21ed9a30cf5229", "panels/ha-panel-history.html": "89062c48c76206cad1cec14ddbb1cbb1", "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab", "panels/ha-panel-logbook.html": "6dd6a16f52117318b202e60f98400163", diff --git a/homeassistant/components/frontend/www_static/core.js b/homeassistant/components/frontend/www_static/core.js index 4e78459c294..d1d4c5de1a0 100644 --- a/homeassistant/components/frontend/www_static/core.js +++ b/homeassistant/components/frontend/www_static/core.js @@ -1 +1 @@ -!function(){"use strict";function e(e){return{type:"auth",api_password:e}}function t(){return{type:"get_states"}}function n(){return{type:"get_config"}}function i(){return{type:"get_services"}}function r(){return{type:"get_panels"}}function s(e,t,n){var i={type:"call_service",domain:e,service:t};return n&&(i.service_data=n),i}function o(e){var t={type:"subscribe_events"};return e&&(t.event_type=e),t}function c(e){return{type:"unsubscribe_events",subscription:e}}function u(){return{type:"ping"}}function a(e,t){return{type:"result",success:!1,error:{code:e,message:t}}}function f(t,n){function i(r,s,o){var c=new WebSocket(t),u=!1,a=function(){if(u)return void o(C);if(0===r)return void o(O);var e=-1===r?-1:r-1;setTimeout(function(){return i(e,s,o)},1e3)},f=function t(i){switch(JSON.parse(i.data).type){case"auth_required":"authToken"in n?c.send(JSON.stringify(e(n.authToken))):(u=!0,c.close());break;case"auth_invalid":u=!0,c.close();break;case"auth_ok":c.removeEventListener("message",t),c.removeEventListener("close",a),s(c)}};c.addEventListener("message",f),c.addEventListener("close",a)}return new Promise(function(e,t){return i(n.setupRetry||0,e,t)})}function d(e){return e.result}function v(e,t){return void 0===t&&(t={}),f(e,t).then(function(n){var i=new j(e,t);return i.setSocket(n),i})}function h(e,t){return e._subscribeConfig?e._subscribeConfig(t):new Promise(function(n,i){var r=null,s=null,o=[],c=null;t&&o.push(t);var u=function(e){r=Object.assign({},r,e);for(var t=0;t6id_{gHklqKeSh=<7hO|DkR#JG6g#0cpmx|{!i zMEidTyL>6!{eejaUbx7lTR_fbsNZG&8pKksLVa=JWfMJhCHlJ+ZT69JcVB^N&}Uu& zono#)-7+zOX+)up_A>GbJCI03j95C!IR$MAg9B`nOaenhdD>)xP%J2h{AL8diX_%9 zm>_H>Xk(O_s1btDYuVzJ{h5dgCJc@57tnuBy06ZL1~_r>37@{a;E3@prYC9|?Nev~ z*Kyc_!#Y*L9%a!O%(2S}W)5uonqo?^M5HURIDW0fQjsNvm=YGM(TJZR(x`$%h9ZTf z?2quQT;=)b3O6z0n7uXNrX0f~BkL*rhe~8I`Xh;XV5_K)mZkIhm=@g$_?Svd=k|Y? z`Sr!*)#}%MDW>a9yKz@1LzrAn$rO|E6psm$-=>WnS&^m#!D?TmC`7Maw{!>;9%{2j z3ODn)Na3$L0t+_w7-2x=`J)kvatQme*^Z_r370{KoGKfQMu1^MnAbi}y{td7#`Omh zI*B^!tEDjDsoaz~wL{O=S)5`rAyt2N=@!hXDh&xs!d!WTa%)o())U!^?9n?x4MLnO zlHtdg@Q0H{RNEB>k_Kj=^x0u6k72#VGW<(W)hJOvH8i9{`dhmO^x06R_71IlK#JWr z7)LmSKquZo1#|*|u7r1>P|*|aqbOWI&v-hGc$J&1EV3z3&|p|U{-I6|VxoU-R){7I zkNHpXNMQ{ch}G0yzC4;+ZfHXe>S}24bbhhQ;TGs51E5o%%is?3EUNSno#ATZCde+B zGl88*(Nnf#@zG6yWNy{5DRKOk+ zWn3XaTqA+5ksz*-z|9RxopOI>MFf3Co0RMw$`Y1RbzXeLl(Ew~W5-p-9_Dfca^f!J zBm_ALLQeYgkdsxAlfDKy5&?qjFiTdkSQ>aRaNXOj1rK2!$y2edhGRSX`PhDH)p{hg zmM)frwM`x0@htl1A^JA;#n_^s*)h7WJ1-1WjRpEVN6&2Hpr1W;gDZa;Q!v_1bg+eT zsTI^WO~#v0zH7F$6EzGZ>RR!DHAmS3zNKsUT=cEcz?eTr zv~X#{Yc07ZhEZ?CB0q%p{zhgxqbq@?YmE9%LFANUiJ3A)BsMToxRw+(UmImTir$W;&i&2t43V<_Hw4M^ot zz8M!Co2W`0j&)7vytP7(cwgua@A~qB2RaG+W%9N+Gfrmr27?iJ$t(x2F0I;(>T+

vn&guW}PdxzP=EdDxbZQJBBN zw7T-{aO_0tQO^xuVNcbbcdUxsx1v8l)T^T36bH2oL0?B=XtM;9Hf8Hxja-!=HIhw; zkjjzf?!GgJwQYY_X(NzZRM)oF=o0-G42s=HSM3c^D|GGH8N5b5!KmEr7!;hknl)Xc zZW6*pG!B0Y(^-QdR@f=3OE(&n(+JP5vh@}M!6Z=12>5|%TP|u(I#PG?b6;ZX2EOQ3 zy|BES*Y;Js@gpZ_B`@sN(X8!+Q$4V>ZqLhCj)2mYdFQwq;!_@v8t&m~Iy3R72NLY0 z3J)A0Jq)Vgx(lksn})F%rUp9;w|II-3u3zIY<7R1nhVcDN@`uW8@sRby6 z@Lzv$_cvd^&aU6z-rhaTuJ3MdZ>}GLJ4S;aZ@<0&^zp;&{l6dn46a#%UZ$Z@aK5jH=+V`uU-FzkI5G z7f1P+2G3}L9Tpnd&j0iM_xtD*tAE|!&pvVPx;^#{hm5AaMCC+r#6QvE%`pc8 z*;#3gVpFSCE?j3xOf*F-B-#4P*(v1&_}`&w`N9d_zI86T)GKt;3+HXT;cs^^FCfxE z=eB)od|AVM@g#&56qvW#`ss~cSGNZ5`gLcybV>Iy=2C`A&q`GydyhP>%3rJYxxZDo zUSJ8lzSo(gCZpc==(>}uYNnlK<^rm^>;0a|N08?2G2ZfJj7&zOa$Ubupo?b54wQP4 j-{R+2cg?Qzwz^c_6*E7SLlNbA;otrTj7dm-IUoQ4nkc^2 delta 2414 zcmV-!36b{s6!sJcABzYGXYdh`2PFsXqD_h>d-}060|9@uaE(eWqe-S%RwyEg1nkbf z!~8x=9zna>V|j#p z1;+Dd68?Xaa|x+aM8ZS%2*Tm$8dnKAhDZw@6h~so7tb!Z!mkNE&kI~U{v}KNWS!wQ zo)^v~#E)UK0~gzP&2fV^VUI3*bb`c}@RV)ghTP#ZR~iIJ*D1~9pn=&D62lea*kj5~ zyu%?({5VaI@mUa`{(Q~kPz4Cu`m4;II7(bw!Sa7IG=xAk>3s7Iw0wSfxnwkZ@EUJQ z8^1pTzwpRrcH81+Jc4{gQ!GXMgU1$zz}G7fjRbYTlYBo6MHPh<&bstX6U&w;tJY@V z&+7QQHb`~VA42~Q29+QfFNBbh$k%J8ijORsOj%;Shj@t0-{fkAOpJ@?O^lFEqPzJI zNVI>Cu*;Xi-5;1_;Dw7!x&`E1hWcIRuR$#JD%2MjUN+HFSE9dL(PkehclQ;T27Trg z&?)8$)GZSem_`)pXfGq5umg!i#E7MnoKw)2FgUeh0F?(ykO*w`~M%GjK50%Jb^hXl&z*bQoElcP1F)g|i@G+H^&h39O z^Xse0tJSajQcTyIcH^#2hA_FDk|`$RDIOChzfBuEvLa0fg4MoAQHWlVMLC51*lb5rlZ4BlLr#^AMkBzmAyTx z37te8_0>`s@Ko-CIkiL2)>)ilG9iCecIi^)RF#H=C1I{SLbRRuT$haz+^rN29X3`s<$?WC{RPG2#lucYM-87g3p zi88K`Ag+->*GLf8NZ{rMrA~i2vm%1NqD@Nn4rK{TsX8w{V#?TyI%CIG#vbN!1ajgo z|L1J)d&vy@rBanO~YaOO%E-XbydaW;~;DOL)Z)WbD4 z*vG^E9ON6_8!|kG>V+XDCv=1>YJ`I+nV0R2^a?Y3F3eX*17ShL?*D3)g^Pwc=l0?i zPg4*B=3*_yHdEM_mFj;dkKtacu9CxbRbgg{hYodr^OqO5D3{6K+fMRmJIT-bhiIj^ z)@ipKcB4`0BgP|7J4bzL)WT=#o^@wv9Q?Eg8GO8p#_T%!js55}8G6N)pUZWGzvydb zie?pQQ&XEEEQFq7k$y^%g*~96iUMrtX@*Vb`3-wK2Q+|-@4+8 zz<`O7Z^323vC4lv)M44y=xV0piG{!e{$!3ov3g_@BNSCxrTF%q14YHJY)jQyeJx=X zG2xqW(XolD#Nk-ibk18V4=L7=M8uKgF+TY=429JjH*l+jYLmO&sM$H`L`}TRui% z{tDCT%Dcm{6RAf%H++RXReRpCDstb7{s2+0ihffZ)G`Ep9f_gM5=`2Zt$Q_cRf5z= zHX%YPN1D6)&K%aZ{avMvKyFc8+ghVb^j|P2b{}1}H$<(_wPR=S8uHL7Xi!cgJiE%)TL=V`Kq({O2c~Vgs6FXO-O0~=iLD#> zqF438@@`(+SMkP=oS>DwuvbU3wi8bEz|y)sFJCzVN>}Ec<7$Xcc|2;kho|Yx#Gf8W zJXU((0O?^+1=n3rE#5SY#V|G4S-8d1J6aIaO=o|z^VD2;7E)5{!rj>YojHg}0?Lb( zOlj(_Ja#?+=%q_qnK$JsDOOVh86LUbwJSZgZ?5`-%Yzr5Tkn7L>N5Cbvp~yZ)!4tq zw~N^3(q0d?n#i9z!Q#Q^?_XlQ@}D>kvVY)--Alp+I>6_s=m`827BM=-jEZT8eXY*5 zkN|)G1$Te*?c417{q61D!|eL*_V(ubA-H2S`04h~_n$v~n7#k^!^hy7CFo@u8YKr? z*13cXR;+hR5G)y{i$6$^dT7Qd`|E!`|4}qkH$?I$N>@|t82qJWoS0D+JXSwH6z`W$ z)$igcpVHtNEwIBvBis2O-+#Z4zOwq){r!LJ!_AjFtfP%u?xS+1IL}8kZ*!)BsC8Ca zqm0y7l?&Hd5))0~_(`_Da&}5N0seQWG`?_xw{M+`F7*n1^1^u=Z}{6C%nLkp(35RP z8ei6!UcCQcxdi56wti=$r_`+hw|XeeQeL3oKHB z*Y`S;)KS#i9$j~GRn4@s%-l9r7rWmx`3TZHG{#%LjFHJ`RIcl{1$3+I*ilh0@>~4; g>SEb--c~oryJF^tawvRUFZ|p801g2zlO`Yl0LTNkHvj+t diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 1fd4bda0275..ff4ce4ba979 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -744,4 +744,4 @@ return performance.now()};else var t=function(){return Date.now()};var e=functio this.hass.callService('media_player', service, serviceData); }, }); -}()); \ No newline at end of file +}()); \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index a9879e06152463cfc798f83f3090f1e2fb761f15..fa38fa165b90aab8600aacdec519ac95eafd48c0 100644 GIT binary patch delta 28803 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 K02S8(#|Qw12(U5$ delta 28798 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 F2mmn

\ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz index e717b38ea4bcf39acbe093a4bc03e6130d72fecf..20c4710b2c45b2ede9866a3a0cf05e77ca80dea5 100644 GIT binary patch literal 43960 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

iZ6qdB_qY3e(q2`DR-Iz;aIAR`i;mk}-L66qc(8 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 literal 40511 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 X9!q!(B5kR!=1=}V)musg)IkCOxoYTS diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html index ca491348298..906a7294b1f 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html @@ -1,17 +1 @@ -

\ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz index 2a18410d99060cdb43efeb316028b7fc579b161f..4e2422b0c61cad8a13d195353777398e5643119e 100644 GIT binary patch literal 392 zcmV;30eAi%iwFognHpIF1889_aA9s`Y%OSEb8~5LE@*UZYyfpq!HU~35d9UcP6^mC zdn-mUw6seurG;&K38Bnd6Qo7b7-_u0c>V8{;w@<@J!$5>nc=c+@ z;WTTTGBLG*0^V6aP-Fx9~a4Wh(*c|4Z)bdn-KjC3i;8Wk)Vy3`)SSm2U; z&T%G+;M=I2-4r!9^;upY?ZDkva+AlnnQdzSu~Q9^28?VmwR6GRnWq8UFHg_d^HQy9 z-oOt;4k0YnZ!v%4)eE>0+q?Uvas(Rn|LCOxX6Q|>IFF%cOJTE}FE(hp><;fAAAf%Q z^yBrFjZM3CkB5lojcj1BZ`PZu3t5HHx$2K0;If&Gd;sfE3no#N2sS7Vxo>AlTpPtX mk{4;r)?aL*xRcvuyDZ!MSL@PLvtC1Uy`EoeA{|mh0ssI`D!+69 literal 7381 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 diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 7315b62aa3d..2ada4d14f39 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","cb06e1d9405fc590c0f1a054b8119128"],["/frontend/panels/dev-event-2db9c218065ef0f61d8d08db8093cad2.html","b5b751e49b1bba55f633ae0d7a92677d"],["/frontend/panels/dev-info-61610e015a411cfc84edd2c4d489e71d.html","6568377ee31cbd78fedc003b317f7faf"],["/frontend/panels/dev-service-415552027cb083badeff5f16080410ed.html","a4b1ec9bfa5bc3529af7783ae56cb55c"],["/frontend/panels/dev-state-d70314913b8923d750932367b1099750.html","c61b5b1461959aac106400e122993e9e"],["/frontend/panels/dev-template-567fbf86735e1b891e40c2f4060fec9b.html","d2853ecf45de1dbadf49fe99a7424ef3"],["/frontend/panels/map-31c592c239636f91e07c7ac232a5ebc4.html","182580419ce2c935ae6ec65502b6db96"],["/static/compatibility-8e4c44b5f4288cc48ec1ba94a9bec812.js","4704a985ad259e324c3d8a0a40f6d937"],["/static/core-8cc30e2ad9ee3df44fe7a17507099d88.js","15703575bd272c8b56e1042b1f2d02a6"],["/static/frontend-5999c8fac69c503b846672cae75a12b0.html","d6ce8eb348fbea599933b2a72beb1337"],["/static/mdi-f407a5a57addbe93817ee1b244d33fbe.html","5459090f217c77747b08d06e0bf73388"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","32b5a9b7ada86304bec6b43d3f2194f0"]],cacheName="sw-precache-v3--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var n=new URL(e);return"/"===n.pathname.slice(-1)&&(n.pathname+=t),n.toString()},cleanResponse=function(e){return e.redirected?("body"in e?Promise.resolve(e.body):e.blob()).then(function(t){return new Response(t,{headers:e.headers,status:e.status,statusText:e.statusText})}):Promise.resolve(e)},createCacheKey=function(e,t,n,a){var c=new URL(e);return a&&c.pathname.match(a)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(n)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var n=new URL(t).pathname;return e.some(function(e){return n.match(e)})},stripIgnoredUrlParameters=function(e,t){var n=new URL(e);return n.search=n.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),n.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],n=e[1],a=new URL(t,self.location),c=createCacheKey(a,hashParamName,n,!1);return[a.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(n){if(!t.has(n)){var a=new Request(n,{credentials:"same-origin"});return fetch(a).then(function(t){if(!t.ok)throw new Error("Request for "+n+" returned a response with status "+t.status);return cleanResponse(t).then(function(t){return e.put(n,t)})})}}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(n){return Promise.all(n.map(function(n){if(!t.has(n.url))return e.delete(n)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,n=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(n);t||(n=addDirectoryIndex(n,"index.html"),t=urlsToCacheKeys.has(n));!t&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(n=new URL("/",self.location).toString(),t=urlsToCacheKeys.has(n)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(n)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url)&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var n,a;for(n=0;n2`}~^iwFohnHpIF19N3^c4=c}Uw3bEYh`jSYI6XkSZj0Jx)J>=3a8g0Yl<^|K0^j$&w`{x6=={K#&p1mEDgb-lnWpmH}0_C8qh)$teW zro8j+PA{$CYV-~a&fsTTTdLX=x{%yHJiCO-JmqC6xcugVZ@e2_H1KBKF1E8byjyo+ zf_~sDZa6;e$_eb8Z}|M-JWl!MdIcA7=O0-Te7!O|rXA?+EK*Q{0o?Cqg&)plv)AG) zNYkU&zw&|-s<~O5oeiJLxj# zOE8OCT{v%EzkBca3*ZWx_QIn3_kQf&I8W=!AeQ6zxLN1I;j2|SY;^Lqtt-d>b&W!r z;y>Noh4UNm2AYv`Ztgr^)av@;AMakj3M_M~Ia<*>?UFa6RRhv82Q}zoKKt~^nK-_a zK%Au!(+nshDi1>%rin;XnUG9C!ky1PeyE$yEHzp23d(k(;BpMs=i^9;Tt*}d69zie z2~n8}GbOSxr;@9vLjm;&CQ?Qq&IJ*IGo}+tIfN?Zd6cB7I<2RubUjW;LP7`$<1ryp z%Pa<^A{nbV%ONFdqb6ZVM$;645-C)gX;2dPBq&LBs<}R`riJFRkT8x3V=M}zREjX8 z0{x;j( zp^S7KCZV=l*r-xb#wbV~GX+H9)^(g~$a9{?Q4E?MSA}V1jA*kwk`c`lnrKuIrZVOD z9B~Fh#v2VJix;N6qEL1;`6juaSAA|$2Ekcwz4 z$r8$y%%eEp21mwI^idX5sU)MZmdN5z2!VjE)G#Q4~caihm~S zC8%P(9E52g6k?KwDbgy^l4S`C3E*}G(m4L9VQ*o+E_pLZqe zOaZ!w$X#iLEesRQFYN%G9eu~YU{%d65lNn7-pLRmG>E6jl@ND`&I$=3DLW}Az<;eCxlpo#+R{^LzMiwsZEuwt5!_cGz7tfYqYwF8>MFdl>qa&wCu@A&w({b|&vp zTv`XY+`GMXWzb@KA{Xbg?eK|n=2+LBLnUhkAKpHHx*;2G_S~5{$KF()=gU186zv;S z_;G>sERi{&9{9Y_uI=t%=;gfL)JNfTpA5#^8b00nuC14Fv=1Kk*0of=ZLwC*JA$e` zhW;`3p|l>6*=8{Ck9a|gO`mZQDerXCK72ECZ0D|v5;Y(D&;;10HP=TN^$4>uwAj^u z3O{>Y|9?`~*Se@Ss`@dS_8=w}yj^q{^r8P{C-xm5PZ;g{dPA+YL*pfC+wVya46x9d znKjU_@P}Zi-+RvQ5=UA0-7)fGwohP}@7shNend9tf8smF`||Uh=S@3#m`l3D%nM7Z z3NgjF#n>san(kT#!4)sehsqSC+wGtYFlSZnHu;@CO&y-$PR9HuN>RQ0Yt!)S09%Wt z`vA@0l9y{}ZSvRyuM-<9eVS~fp*6O=;-!avHFNU?CEP4_$LTpkE@qRtbGRb z+}s?EPr79Ke=z05nZ7#8kLpLxw?}->+luXvuK3L#0ZNvI?F8_b7t5bO)iGrsC$z%( z+q3shmyDa96+wRz9zev{KAB71`!%?QAN2#?)XW}wp(p;-U@59*2VDa41%n$0eYfoS zrv!M@M|j?MDs0f^mIwP=!CG~8c2JJoPtNbI`{a?^cU8B1RovQKRo)H<%`aDV^9>s8 zt8SOPDzuelPxu(c-k<-34cXq#yOv~|`|QbmKl|-wvy4s8w;GCsR*q|$l?w6I=Y1POY4kmD(x+}82$44@jl*|Nro$(?R3Zd z)6`$B+r^O#G41b%(0y3QhMT9>#+bjG7>@M7i~8!-&NY{d)w(dH?I;dmKD=w|?#diw zI~13ooZ(8%-l7e+ZuZAsrQP42c|Qv5-5WPd2=+SbaI2?p1f6b;H3aO&_wMgxSz~fP znC?+KGHKm2z5M;<$%o{`F?*9ef9!-Q}761UzQ0>kD literal 2509 zcmV;;2{QH{iwFp3@DW)819N3^c4=c}Uw3bEYh`jSYI6XkSZj0JxE1{?3a9IkHAN91 z!G~2%W;e~WY4aM#nS4mS9y~52CKRcXkR8|dfA0k;$&w`{+vx{e6hz#|x#vCrXW7D_ zHBBLnGuF#Wnxd`-EtvQEC6_Z$pPI6D!Mkg~ESqWo!8f>VT`w>*sND62ofqbOarlR2 zQ$BbP$G28+Gx!JwSMarsEmds_T}W=fyt;$v2b+XbA#gMVO4@b$)QnYO39vq(V+2GHO24nLesChx^p zkfukk|H%tVsHSFiaEz27XXN;f z6k!5H<#EWgWO*n@t6>) zWfp@{RK_aKa!83crb!ZJVT>khH1Svvkh##D2^le(bDgFc`ot0`7?VHHWVnH$O2df6If+D;(@3QZ z0i#ip3KHfyep~C22|?^goRFO5oJ$fWaTo%jG|wZ*;kX_H^F?V@u_V<(XGt0{AgB(* z5K5}!FbTDFVXaEhj71=I%oI`rUDt80A!h8Vk)GtN@3lz=?S2vVL18pkS% zP{vv?i!r71P*Z~ONYgk)PAg3KQ0O#@vOc@lR-@7FsHsJ5>d|@kG5oDARbY&^Hbyj; z=y_y)(1ak&ET)umt_aqU=x3td6w{em$x1PdBFi*lU}G0Y8W~Lq<`Jco(D-Mv-hwKY z^OZ0SB!ZZvVT!b(Sj4h~g#^%Dfi#YPYS=kUmnCmj(zuLtih*ZXr8FaXqPf7TBQgb9 zj5ObtR@lZc()`*!3`tJE<6p3-rk02#&%20IWPHL?pjn6>Vz5F&kX}dS1o#j4Be^1E z8&6RyShtV~QUDdmr36!SXaAf}p~X(qsH-_b!>=&!Z=tyo)0H(m!mJ^RNRmYiDU$Id zrf5oz)K3WK+tO|@VKMXS3d?&t#N5Nlp{V*ah^Szga7a6zB~ch-_!1FEDnf?ku@0Tf zOTTL}@Aw=hPJ1(4cb(yNG#omoZVROj8kiQ?p}H3KVs9mAWsRNQaXi16R+ytW>U`i0 zN;KFb-*RlU*xM#wM!zr3{AzURV-oxoJHGADx34P&-zSeljBWh3`&AR$XFr|4!G<{Q z=NjyP*v_i}i_EM-=|PLF7Th86PEOp%<&ye(|BH=S6FOZtLQ7iyigO_Uy1;HGtisn=bzew>ud6mCt(|_cfiB9ryQihsllS{(X}i%5CLhxXMw6UR>Ox+qcep%+bnb6RtIfKkt|8bgay{g?34 z>*oKXy1v#$wN}+XqiIiaV#eE9he4nES6jL7_;|u-FX}b5+76AksBL#9-7~;KXC~G_ zzrqiKss0!_zbhPN-A~8JkI629UA}7*a`+ipqyL3(8Sl%>2hSUK^01b4hnY8)R25>1 zd5gJIVmIBk41yb8m`{}{O1JZ%4X|ca?xy%1KTYkQ;g06~I!jT#`)kwi+W<$4x%&jo z;F_09Xl?P>0kG<_0K<$h*bM66>;XK7e=lw9EB_8bQ|NEO%l6D^(efdlE~Z80JZzZS zpKbU7JA3){H_yzP`lhQy0(Xt>eLzZ!axl;KK6BNa8sDspf64K2(zkE z8^t?y*5>LlpdeT*tv9A4@&iWmz=3uv0vPD_Te0|zG3@DUH~2>gV2`WUNUU83^xRw@ zjgN+8{C_az$eF%6$dBqr!M8_z&)bOYkFNO69|201g`EWOlNa-!K-Dp27bmpB`P-`t zrz^%y&x)Ww39mrJ+&)@M-N!xX!jI+wZ)_${qtFw7Y_Jtolf5Yc`GU!f5B;?4`Nsr! z<7ar@cPbpvrj`f0u3)b^IoWGR?ic5G*M0HK?Wd}Hz9{Z(tt#(V2hFRSy7>kT&QrVz)$FJnzOh#tee$W#n0o&^A^QgSRQ?pY z7QMd@gAW_pjr$_8Di9Yl7<4MtV>vK0ZU#5J9l-ZRH#-fAc7P0T(XqhksVX}Q4%7fU zMAxNFx2AHRzpR&~s{Zxd3@qO8sG}=LzqHQ4meSsWv%xR#pYP(0m1K3Lvsvz#f1LV@ zWji}iA;$gv5QYyM+3M!0wK?W*7ls2f@T|Uhw{^|sVz(|#X(x((nD_76y1Oz5*$%}u zC}(x0W^d6}w{G^sUZp+Wo_Rk??Cl#jEC}{GYv0x5H-b*L#~K3G@tylSS=LzG_m+Fq z4lLU6tl&KOn`d`z)!bG2>X`d07RnViH< Date: Tue, 16 May 2017 18:09:21 +0200 Subject: [PATCH 110/135] Update Docker base image to python 3.6 (#7613) From 71ed17b836d16d9e47af2a3235f1148c60b95167 Mon Sep 17 00:00:00 2001 From: Ole-Kenneth Date: Tue, 16 May 2017 18:11:44 +0200 Subject: [PATCH 111/135] Add Content-type: image/jpeg for camera proxy (#7581) * Add Content-type: image/jpeg for camera proxy * Set content_type in constructur --- homeassistant/components/camera/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index f6238d6ae23..79f0757d006 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -269,7 +269,7 @@ class CameraImageView(CameraView): image = yield from camera.async_camera_image() if image: - return web.Response(body=image) + return web.Response(body=image, content_type='image/jpeg') return web.Response(status=500) From 1fafa34eb132fa07d4d152ee4c85829e064cb716 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 16 May 2017 21:57:00 +0200 Subject: [PATCH 112/135] Fix typo and update style to match the other platforms (#7621) --- .../components/image_processing/opencv.py | 38 ++++++------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index e48c14aeea5..e8dd1c8dcb6 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -9,22 +9,15 @@ import logging from homeassistant.core import split_entity_id from homeassistant.components.image_processing import ( - ImageProcessingEntity, - PLATFORM_SCHEMA, -) + ImageProcessingEntity, PLATFORM_SCHEMA) from homeassistant.components.opencv import ( - ATTR_MATCHES, - CLASSIFIER_GROUP_CONFIG, - CONF_CLASSIFIER, - CONF_ENTITY_ID, - CONF_NAME, - process_image, -) - -DEPENDENCIES = ['opencv'] + ATTR_MATCHES, CLASSIFIER_GROUP_CONFIG, CONF_CLASSIFIER, CONF_ENTITY_ID, + CONF_NAME, process_image) _LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['opencv'] + DEFAULT_TIMEOUT = 10 SCAN_INTERVAL = timedelta(seconds=2) @@ -33,18 +26,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(CLASSIFIER_GROUP_CONFIG) def _create_processor_from_config(hass, camera_entity, config): - """Create an OpenCV processor from configurtaion.""" + """Create an OpenCV processor from configuration.""" classifier_config = config[CONF_CLASSIFIER] name = '{} {}'.format( - config[CONF_NAME], - split_entity_id(camera_entity)[1].replace('_', ' ')) + config[CONF_NAME], split_entity_id(camera_entity)[1].replace('_', ' ')) processor = OpenCVImageProcessor( - hass, - camera_entity, - name, - classifier_config, - ) + hass, camera_entity, name, classifier_config) return processor @@ -57,10 +45,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for camera_entity in discovery_info[CONF_ENTITY_ID]: devices.append( - _create_processor_from_config( - hass, - camera_entity, - discovery_info)) + _create_processor_from_config(hass, camera_entity, discovery_info)) add_devices(devices) @@ -115,6 +100,5 @@ class OpenCVImageProcessor(ImageProcessingEntity): def process_image(self, image): """Process the image.""" self._last_image = image - self._matches = process_image(image, - self._classifier_configs, - False) + self._matches = process_image( + image, self._classifier_configs, False) From 9dcc0b5ef5a67466306c183ace7ae66e59196f59 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Wed, 17 May 2017 04:01:29 +0100 Subject: [PATCH 113/135] Bump pyvera - fixes issue with % in brightness levels. (#7622) --- homeassistant/components/vera.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 8eb381a0b85..001105374d7 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.30'] +REQUIREMENTS = ['pyvera==0.2.31'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 55678e55e87..c623a0b95ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -711,7 +711,7 @@ pyunifi==2.12 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.30 +pyvera==0.2.31 # homeassistant.components.notify.html5 pywebpush==1.0.0 From ed5f94fd8a90927416c19c485546730ba691cfae Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 17 May 2017 08:00:46 +0200 Subject: [PATCH 114/135] Add kelvin/brightness_pct alternatives to light.turn_on (#7596) * Refactor color profiles to a class * Refactor into preprocess_turn_on_alternatives * LIFX: use light.preprocess_turn_on_alternatives This avoids the color_name duplication and gains support for profile. * Add kelvin parameter to light.turn_on * Add brightness_pct parameter to light.turn_on * LIFX: accept brightness_pct in effects * Add test of kelvin/brightness_pct conversions --- homeassistant/components/light/__init__.py | 125 ++++++++++++------ .../components/light/lifx/__init__.py | 9 +- .../components/light/lifx/effects.py | 11 +- homeassistant/components/light/services.yaml | 10 +- tests/components/light/test_demo.py | 5 + 5 files changed, 106 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 853fe4d9fb1..d73d21d7b3a 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -50,13 +50,15 @@ ATTR_TRANSITION = "transition" ATTR_RGB_COLOR = "rgb_color" ATTR_XY_COLOR = "xy_color" ATTR_COLOR_TEMP = "color_temp" +ATTR_KELVIN = "kelvin" ATTR_MIN_MIREDS = "min_mireds" ATTR_MAX_MIREDS = "max_mireds" ATTR_COLOR_NAME = "color_name" ATTR_WHITE_VALUE = "white_value" -# int with value 0 .. 255 representing brightness of the light. +# Brightness of the light, 0..255 or percentage ATTR_BRIGHTNESS = "brightness" +ATTR_BRIGHTNESS_PCT = "brightness_pct" # String representing a profile (built-in ones or external defined). ATTR_PROFILE = "profile" @@ -92,18 +94,21 @@ PROP_TO_ATTR = { # Service call validation schemas VALID_TRANSITION = vol.All(vol.Coerce(float), vol.Clamp(min=0, max=6553)) VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)) +VALID_BRIGHTNESS_PCT = vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) LIGHT_TURN_ON_SCHEMA = vol.Schema({ ATTR_ENTITY_ID: cv.entity_ids, ATTR_PROFILE: cv.string, ATTR_TRANSITION: VALID_TRANSITION, ATTR_BRIGHTNESS: VALID_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, ATTR_COLOR_NAME: cv.string, ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple)), ATTR_XY_COLOR: vol.All(vol.ExactSequence((cv.small_float, cv.small_float)), vol.Coerce(tuple)), ATTR_COLOR_TEMP: vol.All(vol.Coerce(int), vol.Range(min=1)), + ATTR_KELVIN: vol.All(vol.Coerce(int), vol.Range(min=0)), ATTR_WHITE_VALUE: vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), ATTR_FLASH: vol.In([FLASH_SHORT, FLASH_LONG]), ATTR_EFFECT: cv.string, @@ -142,20 +147,21 @@ def is_on(hass, entity_id=None): def turn_on(hass, entity_id=None, transition=None, brightness=None, - rgb_color=None, xy_color=None, color_temp=None, white_value=None, + brightness_pct=None, rgb_color=None, xy_color=None, + color_temp=None, kelvin=None, white_value=None, profile=None, flash=None, effect=None, color_name=None): """Turn all or specified light on.""" hass.add_job( - async_turn_on, hass, entity_id, transition, brightness, - rgb_color, xy_color, color_temp, white_value, + async_turn_on, hass, entity_id, transition, brightness, brightness_pct, + rgb_color, xy_color, color_temp, kelvin, white_value, profile, flash, effect, color_name) @callback def async_turn_on(hass, entity_id=None, transition=None, brightness=None, - rgb_color=None, xy_color=None, color_temp=None, - white_value=None, profile=None, flash=None, effect=None, - color_name=None): + brightness_pct=None, rgb_color=None, xy_color=None, + color_temp=None, kelvin=None, white_value=None, + profile=None, flash=None, effect=None, color_name=None): """Turn all or specified light on.""" data = { key: value for key, value in [ @@ -163,9 +169,11 @@ def async_turn_on(hass, entity_id=None, transition=None, brightness=None, (ATTR_PROFILE, profile), (ATTR_TRANSITION, transition), (ATTR_BRIGHTNESS, brightness), + (ATTR_BRIGHTNESS_PCT, brightness_pct), (ATTR_RGB_COLOR, rgb_color), (ATTR_XY_COLOR, xy_color), (ATTR_COLOR_TEMP, color_temp), + (ATTR_KELVIN, kelvin), (ATTR_WHITE_VALUE, white_value), (ATTR_FLASH, flash), (ATTR_EFFECT, effect), @@ -207,6 +215,27 @@ def toggle(hass, entity_id=None, transition=None): hass.services.call(DOMAIN, SERVICE_TOGGLE, data) +def preprocess_turn_on_alternatives(params): + """Processing extra data for turn light on request.""" + profile = Profiles.get(params.pop(ATTR_PROFILE, None)) + if profile is not None: + params.setdefault(ATTR_XY_COLOR, profile[:2]) + params.setdefault(ATTR_BRIGHTNESS, profile[2]) + + color_name = params.pop(ATTR_COLOR_NAME, None) + if color_name is not None: + params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + + kelvin = params.pop(ATTR_KELVIN, None) + if kelvin is not None: + mired = color_util.color_temperature_kelvin_to_mired(kelvin) + params[ATTR_COLOR_TEMP] = mired + + brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None) + if brightness_pct is not None: + params[ATTR_BRIGHTNESS] = int(255 * brightness_pct/100) + + @asyncio.coroutine def async_setup(hass, config): """Expose light control via statemachine and services.""" @@ -215,10 +244,8 @@ def async_setup(hass, config): yield from component.async_setup(config) # load profiles from files - profiles = yield from hass.loop.run_in_executor( - None, _load_profile_data, hass) - - if profiles is None: + profiles_valid = yield from Profiles.load_profiles(hass) + if not profiles_valid: return False @asyncio.coroutine @@ -231,17 +258,7 @@ def async_setup(hass, config): target_lights = component.async_extract_from_service(service) params.pop(ATTR_ENTITY_ID, None) - # Processing extra data for turn light on request. - profile = profiles.get(params.pop(ATTR_PROFILE, None)) - - if profile: - params.setdefault(ATTR_XY_COLOR, profile[:2]) - params.setdefault(ATTR_BRIGHTNESS, profile[2]) - - color_name = params.pop(ATTR_COLOR_NAME, None) - - if color_name is not None: - params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + preprocess_turn_on_alternatives(params) for light in target_lights: if service.service == SERVICE_TURN_ON: @@ -287,31 +304,51 @@ def async_setup(hass, config): return True -def _load_profile_data(hass): - """Load built-in profiles and custom profiles.""" - profile_paths = [os.path.join(os.path.dirname(__file__), - LIGHT_PROFILES_FILE), - hass.config.path(LIGHT_PROFILES_FILE)] - profiles = {} +class Profiles: + """Representation of available color profiles.""" - for profile_path in profile_paths: - if not os.path.isfile(profile_path): - continue - with open(profile_path) as inp: - reader = csv.reader(inp) + _all = None - # Skip the header - next(reader, None) + @classmethod + @asyncio.coroutine + def load_profiles(cls, hass): + """Load and cache profiles.""" + def load_profile_data(hass): + """Load built-in profiles and custom profiles.""" + profile_paths = [os.path.join(os.path.dirname(__file__), + LIGHT_PROFILES_FILE), + hass.config.path(LIGHT_PROFILES_FILE)] + profiles = {} - try: - for rec in reader: - profile, color_x, color_y, brightness = PROFILE_SCHEMA(rec) - profiles[profile] = (color_x, color_y, brightness) - except vol.MultipleInvalid as ex: - _LOGGER.error("Error parsing light profile from %s: %s", - profile_path, ex) - return None - return profiles + for profile_path in profile_paths: + if not os.path.isfile(profile_path): + continue + with open(profile_path) as inp: + reader = csv.reader(inp) + + # Skip the header + next(reader, None) + + try: + for rec in reader: + profile, color_x, color_y, brightness = \ + PROFILE_SCHEMA(rec) + profiles[profile] = (color_x, color_y, brightness) + except vol.MultipleInvalid as ex: + _LOGGER.error( + "Error parsing light profile from %s: %s", + profile_path, ex) + return None + return profiles + + cls._all = yield from hass.loop.run_in_executor( + None, load_profile_data, hass) + return cls._all is not None + + @classmethod + def get(cls, name): + """Return a named profile.""" + return cls._all.get(name) class Light(ToggleEntity): diff --git a/homeassistant/components/light/lifx/__init__.py b/homeassistant/components/light/lifx/__init__.py index 17512f5dd3b..c264fec35c5 100644 --- a/homeassistant/components/light/lifx/__init__.py +++ b/homeassistant/components/light/lifx/__init__.py @@ -18,10 +18,11 @@ import voluptuous as vol from homeassistant.components.light import ( Light, DOMAIN, PLATFORM_SCHEMA, LIGHT_TURN_ON_SCHEMA, - ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_RGB_COLOR, + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_XY_COLOR, ATTR_COLOR_TEMP, ATTR_TRANSITION, ATTR_EFFECT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, - SUPPORT_XY_COLOR, SUPPORT_TRANSITION, SUPPORT_EFFECT) + SUPPORT_XY_COLOR, SUPPORT_TRANSITION, SUPPORT_EFFECT, + preprocess_turn_on_alternatives) from homeassistant.config import load_yaml_config_file from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_temperature_kelvin_to_mired) @@ -434,9 +435,7 @@ class LIFXLight(Light): if hsbk is not None: return [hsbk, True] - color_name = kwargs.pop(ATTR_COLOR_NAME, None) - if color_name is not None: - kwargs[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + preprocess_turn_on_alternatives(kwargs) if ATTR_RGB_COLOR in kwargs: hue, saturation, brightness = \ diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index 315759738f9..0a8c9cbf80f 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -6,8 +6,9 @@ import random import voluptuous as vol from homeassistant.components.light import ( - DOMAIN, ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_RGB_COLOR, ATTR_EFFECT, - ATTR_TRANSITION) + DOMAIN, ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, + ATTR_RGB_COLOR, ATTR_EFFECT, ATTR_TRANSITION, + VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT) from homeassistant.const import (ATTR_ENTITY_ID) import homeassistant.helpers.config_validation as cv @@ -36,7 +37,8 @@ LIFX_EFFECT_SCHEMA = vol.Schema({ }) LIFX_EFFECT_BREATHE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ - ATTR_BRIGHTNESS: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)), + ATTR_BRIGHTNESS: VALID_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, ATTR_COLOR_NAME: cv.string, ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple)), @@ -49,7 +51,8 @@ LIFX_EFFECT_BREATHE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ LIFX_EFFECT_PULSE_SCHEMA = LIFX_EFFECT_BREATHE_SCHEMA LIFX_EFFECT_COLORLOOP_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ - ATTR_BRIGHTNESS: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)), + ATTR_BRIGHTNESS: VALID_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, vol.Optional(ATTR_PERIOD, default=60): vol.All(vol.Coerce(float), vol.Clamp(min=0.05)), vol.Optional(ATTR_CHANGE, default=20): diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 495ef9c8b39..6ccd45dda66 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -26,7 +26,11 @@ turn_on: color_temp: description: Color temperature for the light in mireds - example: '250' + example: 250 + + kelvin: + description: Color temperature for the light in Kelvin + example: 4000 white_value: description: Number between 0..255 indicating level of white @@ -36,6 +40,10 @@ turn_on: description: Number between 0..255 indicating brightness example: 120 + brightness_pct: + description: Number between 0..100 indicating percentage of full brightness + example: 47 + profile: description: Name of a light profile to use example: relax diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index 9e318ea9192..8d717b1d89a 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -56,6 +56,11 @@ class TestDemoLight(unittest.TestCase): self.assertEqual(154, state.attributes.get(light.ATTR_MIN_MIREDS)) self.assertEqual(500, state.attributes.get(light.ATTR_MAX_MIREDS)) self.assertEqual('none', state.attributes.get(light.ATTR_EFFECT)) + light.turn_on(self.hass, ENTITY_LIGHT, kelvin=4000, brightness_pct=50) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_LIGHT) + self.assertEqual(250, state.attributes.get(light.ATTR_COLOR_TEMP)) + self.assertEqual(127, state.attributes.get(light.ATTR_BRIGHTNESS)) def test_turn_off(self): """Test light turn off method.""" From d2ed3a131f1db07fe4d664053a26ecbd11a296bb Mon Sep 17 00:00:00 2001 From: Conrad Juhl Andersen Date: Wed, 17 May 2017 08:26:57 +0200 Subject: [PATCH 115/135] Add support for disabling tradfri groups (#7593) * Add support for disabling tradfri groups * Fixed styleguide problems * Fix style problems * Use default for groups when setting up in UI --- homeassistant/components/light/tradfri.py | 8 +++++--- homeassistant/components/tradfri.py | 24 ++++++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index fdd02d73349..28c42c5699c 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ( SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) from homeassistant.components.light import \ PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA -from homeassistant.components.tradfri import KEY_GATEWAY +from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) @@ -35,8 +35,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): lights = [dev for dev in devices if dev.has_light_control] add_devices(Tradfri(light) for light in lights) - groups = gateway.get_groups() - add_devices(TradfriGroup(group) for group in groups) + allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] + if allow_tradfri_groups: + groups = gateway.get_groups() + add_devices(TradfriGroup(group) for group in groups) class TradfriGroup(Light): diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index e1ef0f5fabd..cd83f81afd1 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -17,16 +17,22 @@ from homeassistant.const import CONF_HOST, CONF_API_KEY from homeassistant.loader import get_component from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI +REQUIREMENTS = ['pytradfri==1.1'] + DOMAIN = 'tradfri' CONFIG_FILE = 'tradfri.conf' KEY_CONFIG = 'tradfri_configuring' KEY_GATEWAY = 'tradfri_gateway' -REQUIREMENTS = ['pytradfri==1.1'] +KEY_TRADFRI_GROUPS = 'tradfri_allow_tradfri_groups' +CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups' +DEFAULT_ALLOW_TRADFRI_GROUPS = True CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Inclusive(CONF_HOST, 'gateway'): cv.string, vol.Inclusive(CONF_API_KEY, 'gateway'): cv.string, + vol.Optional(CONF_ALLOW_TRADFRI_GROUPS, + default=DEFAULT_ALLOW_TRADFRI_GROUPS): cv.boolean, }) }, extra=vol.ALLOW_EXTRA) @@ -47,7 +53,8 @@ def request_configuration(hass, config, host): def configuration_callback(callback_data): """Handle the submitted configuration.""" res = yield from _setup_gateway(hass, config, host, - callback_data.get('key')) + callback_data.get('key'), + DEFAULT_ALLOW_TRADFRI_GROUPS) if not res: hass.async_add_job(configurator.notify_errors, instance, "Unable to connect.") @@ -77,6 +84,7 @@ def async_setup(hass, config): conf = config.get(DOMAIN, {}) host = conf.get(CONF_HOST) key = conf.get(CONF_API_KEY) + allow_tradfri_groups = conf.get(CONF_ALLOW_TRADFRI_GROUPS) @asyncio.coroutine def gateway_discovered(service, info): @@ -85,7 +93,8 @@ def async_setup(hass, config): host = info['host'] if host in keys: - yield from _setup_gateway(hass, config, host, keys[host]['key']) + yield from _setup_gateway(hass, config, host, keys[host]['key'], + allow_tradfri_groups) else: hass.async_add_job(request_configuration, hass, config, host) @@ -94,11 +103,12 @@ def async_setup(hass, config): if host is None: return True - return (yield from _setup_gateway(hass, config, host, key)) + return (yield from _setup_gateway(hass, config, host, key, + allow_tradfri_groups)) @asyncio.coroutine -def _setup_gateway(hass, hass_config, host, key): +def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): """Create a gateway.""" from pytradfri import cli_api_factory, Gateway, RequestError, retry_timeout @@ -112,6 +122,10 @@ def _setup_gateway(hass, hass_config, host, key): hass.data.setdefault(KEY_GATEWAY, {}) gateways = hass.data[KEY_GATEWAY] + hass.data.setdefault(KEY_TRADFRI_GROUPS, {}) + tradfri_groups = hass.data[KEY_TRADFRI_GROUPS] + tradfri_groups[gateway_id] = allow_tradfri_groups + # Check if already set up if gateway_id in gateways: return True From f0b2a6d0e63e1a8810d4c85d495ecad590fc1ff4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 17 May 2017 09:14:11 +0200 Subject: [PATCH 116/135] Update docstrings and comments (#7626) --- .../image_processing/openalpr_cloud.py | 13 ++++----- .../image_processing/openalpr_local.py | 28 +++++++++---------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/image_processing/openalpr_cloud.py b/homeassistant/components/image_processing/openalpr_cloud.py index 1143c1e04ae..2fdc3d72f2e 100644 --- a/homeassistant/components/image_processing/openalpr_cloud.py +++ b/homeassistant/components/image_processing/openalpr_cloud.py @@ -1,17 +1,18 @@ """ Component that will help set the OpenALPR cloud for ALPR processing. -For more details about this component, please refer to the documentation at +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.openalpr_cloud/ """ import asyncio -from base64 import b64encode import logging +from base64 import b64encode import aiohttp import async_timeout import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.core import split_entity_id from homeassistant.const import CONF_API_KEY from homeassistant.components.image_processing import ( @@ -19,7 +20,6 @@ from homeassistant.components.image_processing import ( from homeassistant.components.image_processing.openalpr_local import ( ImageProcessingAlprEntity) from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -44,8 +44,7 @@ CONF_REGION = 'region' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_REGION): - vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)), + vol.Required(CONF_REGION): vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)), }) @@ -70,7 +69,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class OpenAlprCloudEntity(ImageProcessingAlprEntity): - """OpenALPR cloud entity.""" + """Representation of an OpenALPR cloud entity.""" def __init__(self, camera_entity, params, confidence, name=None): """Initialize OpenALPR cloud API.""" @@ -129,7 +128,7 @@ class OpenAlprCloudEntity(ImageProcessingAlprEntity): _LOGGER.error("Timeout for OpenALPR API") return - # processing api data + # Processing API data vehicles = 0 result = {} diff --git a/homeassistant/components/image_processing/openalpr_local.py b/homeassistant/components/image_processing/openalpr_local.py index 05ca2cffcd0..b0ef93611ea 100644 --- a/homeassistant/components/image_processing/openalpr_local.py +++ b/homeassistant/components/image_processing/openalpr_local.py @@ -1,19 +1,19 @@ """ Component that will help set the OpenALPR local for ALPR processing. -For more details about this component, please refer to the documentation at +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.openalpr_local/ """ import asyncio -import logging import io +import logging import re import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.core import split_entity_id, callback from homeassistant.const import STATE_UNKNOWN -import homeassistant.helpers.config_validation as cv from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingEntity, CONF_CONFIDENCE, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME, ATTR_ENTITY_ID, ATTR_CONFIDENCE) @@ -45,15 +45,13 @@ OPENALPR_REGIONS = [ 'vn2' ] - -CONF_REGION = 'region' CONF_ALPR_BIN = 'alp_bin' +CONF_REGION = 'region' DEFAULT_BINARY = 'alpr' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_REGION): - vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)), + vol.Required(CONF_REGION): vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)), vol.Optional(CONF_ALPR_BIN, default=DEFAULT_BINARY): cv.string, }) @@ -77,9 +75,9 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): """Base entity class for ALPR image processing.""" def __init__(self): - """Initialize base alpr entity.""" - self.plates = {} # last scan data - self.vehicles = 0 # vehicles count + """Initialize base ALPR entity.""" + self.plates = {} + self.vehicles = 0 @property def state(self): @@ -128,7 +126,7 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): if confidence >= self.confidence} new_plates = set(plates) - set(self.plates) - # send events + # Send events for i_plate in new_plates: self.hass.async_add_job( self.hass.bus.async_fire, EVENT_FOUND_PLATE, { @@ -138,7 +136,7 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): } ) - # update entity store + # Update entity store self.plates = plates self.vehicles = vehicles @@ -192,7 +190,7 @@ class OpenAlprLocalEntity(ImageProcessingAlprEntity): stderr=asyncio.subprocess.DEVNULL ) - # send image + # Send image stdout, _ = yield from alpr.communicate(input=image) stdout = io.StringIO(str(stdout, 'utf-8')) @@ -204,12 +202,12 @@ class OpenAlprLocalEntity(ImageProcessingAlprEntity): new_plates = RE_ALPR_PLATE.search(line) new_result = RE_ALPR_RESULT.search(line) - # found new vehicle + # Found new vehicle if new_plates: vehicles += 1 continue - # found plate result + # Found plate result if new_result: try: result.update( From 3b69de8a1a325ea66f35fd6a41f46fcc74a2bef5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 17 May 2017 09:14:28 +0200 Subject: [PATCH 117/135] Upgrade Sphinx to 1.6.1 (#7624) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index b147fcca7a7..8e85b302a6b 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.5.5 +Sphinx==1.6.1 sphinx-autodoc-typehints==1.2.0 sphinx-autodoc-annotation==1.0.post1 From 0e9728d94aa1bdc7da946ae4d817e874db88cad2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 17 May 2017 10:10:35 +0200 Subject: [PATCH 118/135] Update docstrings (#7630) --- .../components/image_processing/demo.py | 10 +++++----- .../image_processing/dlib_face_detect.py | 10 +++++----- .../image_processing/dlib_face_identify.py | 10 +++++----- .../image_processing/microsoft_face_detect.py | 4 ++-- .../image_processing/microsoft_face_identify.py | 17 ++++++++--------- .../components/image_processing/opencv.py | 2 +- 6 files changed, 26 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/image_processing/demo.py b/homeassistant/components/image_processing/demo.py index a7f4ad5e3d6..788d12520f5 100644 --- a/homeassistant/components/image_processing/demo.py +++ b/homeassistant/components/image_processing/demo.py @@ -1,7 +1,7 @@ """ Support for the demo image processing. -For more details about this component, please refer to the documentation at +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/demo/ """ from homeassistant.components.image_processing import ATTR_CONFIDENCE @@ -12,7 +12,7 @@ from homeassistant.components.image_processing.microsoft_face_identify import ( def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the demo image_processing platform.""" + """Set up the demo image processing platform.""" add_devices([ DemoImageProcessingAlpr('camera.demo_camera', "Demo Alpr"), DemoImageProcessingFace( @@ -21,10 +21,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class DemoImageProcessingAlpr(ImageProcessingAlprEntity): - """Demo alpr image processing entity.""" + """Demo ALPR image processing entity.""" def __init__(self, camera_entity, name): - """Initialize demo alpr.""" + """Initialize demo ALPR image processing entity.""" super().__init__() self._name = name @@ -61,7 +61,7 @@ class DemoImageProcessingFace(ImageProcessingFaceEntity): """Demo face identify image processing entity.""" def __init__(self, camera_entity, name): - """Initialize demo alpr.""" + """Initialize demo face image processing entity.""" super().__init__() self._name = name diff --git a/homeassistant/components/image_processing/dlib_face_detect.py b/homeassistant/components/image_processing/dlib_face_detect.py index 5877535d3e0..3308edf53b5 100644 --- a/homeassistant/components/image_processing/dlib_face_detect.py +++ b/homeassistant/components/image_processing/dlib_face_detect.py @@ -1,7 +1,7 @@ """ -Component that will help set the dlib face detect processing. +Component that will help set the Dlib face detect processing. -For more details about this component, please refer to the documentation at +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.dlib_face_detect/ """ import logging @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Microsoft Face detection platform.""" + """Set up the Dlib Face detection platform.""" entities = [] for camera in config[CONF_SOURCE]: entities.append(DlibFaceDetectEntity( @@ -35,7 +35,7 @@ class DlibFaceDetectEntity(ImageProcessingFaceEntity): """Dlib Face API entity for identify.""" def __init__(self, camera_entity, name=None): - """Initialize Dlib.""" + """Initialize Dlib face entity.""" super().__init__() self._camera = camera_entity @@ -62,7 +62,7 @@ class DlibFaceDetectEntity(ImageProcessingFaceEntity): import face_recognition fak_file = io.BytesIO(image) - fak_file.name = "snapshot.jpg" + fak_file.name = 'snapshot.jpg' fak_file.seek(0) image = face_recognition.load_image_file(fak_file) diff --git a/homeassistant/components/image_processing/dlib_face_identify.py b/homeassistant/components/image_processing/dlib_face_identify.py index a0f50796a9f..8baa7ac7ec3 100644 --- a/homeassistant/components/image_processing/dlib_face_identify.py +++ b/homeassistant/components/image_processing/dlib_face_identify.py @@ -1,7 +1,7 @@ """ -Component that will help set the dlib face detect processing. +Component that will help set the Dlib face detect processing. -For more details about this component, please refer to the documentation at +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.dlib_face_identify/ """ import logging @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Microsoft Face detection platform.""" + """Set up the Dlib Face detection platform.""" entities = [] for camera in config[CONF_SOURCE]: entities.append(DlibFaceIdentifyEntity( @@ -43,7 +43,7 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): """Dlib Face API entity for identify.""" def __init__(self, camera_entity, faces, name=None): - """Initialize Dlib.""" + """Initialize Dlib face identify entry.""" # pylint: disable=import-error import face_recognition super().__init__() @@ -77,7 +77,7 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): import face_recognition fak_file = io.BytesIO(image) - fak_file.name = "snapshot.jpg" + fak_file.name = 'snapshot.jpg' fak_file.seek(0) image = face_recognition.load_image_file(fak_file) diff --git a/homeassistant/components/image_processing/microsoft_face_detect.py b/homeassistant/components/image_processing/microsoft_face_detect.py index 5c4daec1067..40aac61914b 100644 --- a/homeassistant/components/image_processing/microsoft_face_detect.py +++ b/homeassistant/components/image_processing/microsoft_face_detect.py @@ -1,7 +1,7 @@ """ -Component that will help set the microsoft face detect processing. +Component that will help set the Microsoft face detect processing. -For more details about this component, please refer to the documentation at +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.microsoft_face_detect/ """ import asyncio diff --git a/homeassistant/components/image_processing/microsoft_face_identify.py b/homeassistant/components/image_processing/microsoft_face_identify.py index a645aef2fcb..0cdd1675274 100644 --- a/homeassistant/components/image_processing/microsoft_face_identify.py +++ b/homeassistant/components/image_processing/microsoft_face_identify.py @@ -1,5 +1,5 @@ """ -Component that will help set the microsoft face for verify processing. +Component that will help set the Microsoft face for verify processing. For more details about this component, please refer to the documentation at https://home-assistant.io/components/image_processing.microsoft_face_identify/ @@ -62,8 +62,8 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): def __init__(self): """Initialize base face identify/verify entity.""" - self.faces = [] # last scan data - self.total_faces = 0 # face count + self.faces = [] + self.total_faces = 0 @property def state(self): @@ -71,11 +71,11 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): confidence = 0 state = STATE_UNKNOWN - # no confidence support + # No confidence support if not self.confidence: return self.total_faces - # search high confidence + # Search high confidence for face in self.faces: if ATTR_CONFIDENCE not in face: continue @@ -128,7 +128,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): This method must be run in the event loop. """ - # send events + # Send events for face in faces: if ATTR_CONFIDENCE in face and self.confidence: if face[ATTR_CONFIDENCE] < self.confidence: @@ -139,7 +139,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): self.hass.bus.async_fire, EVENT_DETECT_FACE, face ) - # update entity store + # Update entity store self.faces = faces self.total_faces = total @@ -200,7 +200,7 @@ class MicrosoftFaceIdentifyEntity(ImageProcessingFaceEntity): _LOGGER.error("Can't process image on Microsoft face: %s", err) return - # parse data + # Parse data knwon_faces = [] total = 0 for face in detect: @@ -220,5 +220,4 @@ class MicrosoftFaceIdentifyEntity(ImageProcessingFaceEntity): ATTR_CONFIDENCE: data['confidence'] * 100, }) - # process data self.async_process_faces(knwon_faces, total) diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index e8dd1c8dcb6..e6cdd0fcef9 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -1,7 +1,7 @@ """ Component that performs OpenCV classification on images. -For more details about this component, please refer to the documentation at +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.opencv/ """ from datetime import timedelta From f7d25396a463fcfb8afbb50199b3128413fefd31 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Wed, 17 May 2017 14:42:47 +0200 Subject: [PATCH 119/135] Kodi specific service to call Kodi API methods (#7603) * Kodi specific services to call Kodi API methods - new service: `kodi_execute_addon` to run a Kodi Addon with optional parameters. Results of the Kodi API call, if any, are redirected in a Home Assistant event: `kodi_execute_addon_result`. - new service: `kodi_run_method` to run a Kodi JSONRPC API method with optional parameters. Results of the Kodi API call are redirected in a Home Assistant event: `kodi_run_method_result`. - Add descriptions in services.yaml. - Add `timeout` parameter to yaml config (needed to make slow queries to the JSONRPC API, default timeout is set to 5s). - Trigger events with the results of the Kodi API calls, with: ``` event_data = { 'result': api_call_results, 'result_ok': boolean, 'input': api_call_parameters, 'entity_id': 'media_player.kodi'} ``` * no need to clean OrderedDicts; no need for the `kodi_execute_addon` service * no need for the `kodi_execute_addon` service * unused import * naming changes --- homeassistant/components/media_player/kodi.py | 44 +++++++++++++++++-- .../components/media_player/services.yaml | 11 +++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 18c01c396ac..9861887df89 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -24,7 +24,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, CONF_PROXY_SSL, CONF_USERNAME, CONF_PASSWORD, - EVENT_HOMEASSISTANT_STOP) + CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -34,6 +34,8 @@ REQUIREMENTS = ['jsonrpc-async==0.6', 'jsonrpc-websocket==0.5'] _LOGGER = logging.getLogger(__name__) +EVENT_KODI_CALL_METHOD_RESULT = 'kodi_call_method_result' + CONF_TCP_PORT = 'tcp_port' CONF_TURN_OFF_ACTION = 'turn_off_action' CONF_ENABLE_WEBSOCKET = 'enable_websocket' @@ -74,6 +76,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean, vol.Optional(CONF_TURN_OFF_ACTION, default=None): vol.In(TURN_OFF_ACTION), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Inclusive(CONF_USERNAME, 'auth'): cv.string, vol.Inclusive(CONF_PASSWORD, 'auth'): cv.string, vol.Optional(CONF_ENABLE_WEBSOCKET, default=DEFAULT_ENABLE_WEBSOCKET): @@ -81,6 +84,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) SERVICE_ADD_MEDIA = 'kodi_add_to_playlist' +SERVICE_CALL_METHOD = 'kodi_call_method' DATA_KODI = 'kodi' @@ -88,6 +92,7 @@ ATTR_MEDIA_TYPE = 'media_type' ATTR_MEDIA_NAME = 'media_name' ATTR_MEDIA_ARTIST_NAME = 'artist_name' ATTR_MEDIA_ID = 'media_id' +ATTR_METHOD = 'method' MEDIA_PLAYER_ADD_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ vol.Required(ATTR_MEDIA_TYPE): cv.string, @@ -95,11 +100,17 @@ MEDIA_PLAYER_ADD_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ vol.Optional(ATTR_MEDIA_NAME): cv.string, vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string, }) +MEDIA_PLAYER_CALL_METHOD_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_METHOD): cv.string, +}, extra=vol.ALLOW_EXTRA) SERVICE_TO_METHOD = { SERVICE_ADD_MEDIA: { 'method': 'async_add_media_to_playlist', 'schema': MEDIA_PLAYER_ADD_MEDIA_SCHEMA}, + SERVICE_CALL_METHOD: { + 'method': 'async_call_method', + 'schema': MEDIA_PLAYER_CALL_METHOD_SCHEMA}, } @@ -127,7 +138,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): host=host, port=port, tcp_port=tcp_port, encryption=encryption, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), - turn_off_action=config.get(CONF_TURN_OFF_ACTION), websocket=websocket) + turn_off_action=config.get(CONF_TURN_OFF_ACTION), + timeout=config.get(CONF_TIMEOUT), websocket=websocket) hass.data[DATA_KODI].append(entity) async_add_devices([entity], update_before_add=True) @@ -199,7 +211,7 @@ class KodiDevice(MediaPlayerDevice): def __init__(self, hass, name, host, port, tcp_port, encryption=False, username=None, password=None, turn_off_action=None, - websocket=True): + timeout=DEFAULT_TIMEOUT, websocket=True): """Initialize the Kodi device.""" import jsonrpc_async import jsonrpc_websocket @@ -207,7 +219,7 @@ class KodiDevice(MediaPlayerDevice): self._name = name kwargs = { - 'timeout': DEFAULT_TIMEOUT, + 'timeout': timeout, 'session': async_get_clientsession(hass), } @@ -678,6 +690,30 @@ class KodiDevice(MediaPlayerDevice): yield from self.server.Player.SetShuffle( {"playerid": self._players[0]['playerid'], "shuffle": shuffle}) + @asyncio.coroutine + def async_call_method(self, method, **kwargs): + """Run Kodi JSONRPC API method with params.""" + import jsonrpc_base + _LOGGER.debug('Run API method "%s", kwargs=%s', method, kwargs) + result_ok = False + try: + result = yield from getattr(self.server, method)(**kwargs) + result_ok = True + except jsonrpc_base.jsonrpc.ProtocolError as exc: + result = exc.args[2]['error'] + _LOGGER.error('Run API method %s.%s(%s) error: %s', + self.entity_id, method, kwargs, result) + + if isinstance(result, dict): + event_data = {'entity_id': self.entity_id, + 'result': result, + 'result_ok': result_ok, + 'input': {'method': method, 'params': kwargs}} + _LOGGER.debug('EVENT kodi_call_method_result: %s', event_data) + self.hass.bus.async_fire(EVENT_KODI_CALL_METHOD_RESULT, + event_data=event_data) + return result + @asyncio.coroutine def async_add_media_to_playlist( self, media_type, media_id=None, media_name='ALL', artist_name=''): diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 4d5f85c05eb..00ce0987fd9 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -289,3 +289,14 @@ kodi_add_to_playlist: artist_name: description: Optional artist name for filtering media. example: 'AC/DC' + +kodi_call_method: + description: 'Call a Kodi JSONRPC API method with optional parameters. Results of the Kodi API call will be redirected in a Home Assistant event: `kodi_call_method_result`.' + + fields: + entity_id: + description: Name(s) of the Kodi entities where to run the API method. + example: 'media_player.living_room_kodi' + method: + description: Name of the Kodi JSONRPC API method to be called. + example: 'VideoLibrary.GetRecentlyAddedEpisodes' From 76b747edd689f04fefa15ebd631b03fd5d1d1de1 Mon Sep 17 00:00:00 2001 From: corneyl Date: Wed, 17 May 2017 18:05:34 +0200 Subject: [PATCH 120/135] Updated limitlessled requirement to v1.0.8 (#7629) --- 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 c3bf8d807da..4e44351b7dd 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['limitlessled==1.0.7'] +REQUIREMENTS = ['limitlessled==1.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c623a0b95ba..b4b478bb062 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -354,7 +354,7 @@ libsoundtouch==0.3.0 liffylights==0.9.4 # homeassistant.components.light.limitlessled -limitlessled==1.0.7 +limitlessled==1.0.8 # homeassistant.components.media_player.liveboxplaytv liveboxplaytv==1.4.9 From f3b9e1e988db13b377332eb39a37d7b27064c7ca Mon Sep 17 00:00:00 2001 From: Riccardo Canta Date: Wed, 17 May 2017 23:26:58 +0200 Subject: [PATCH 121/135] Osram lightify Removed wrong assignment (#7615) self._brightness is assigned with the returned value of the set_luminance() function, which is always equal to None. --- homeassistant/components/light/osramlightify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index 143dc52cbee..bc0cfacda1a 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -195,7 +195,7 @@ class Luminary(Light): self._brightness = kwargs[ATTR_BRIGHTNESS] _LOGGER.debug("turn_on requested brightness for light: %s is: %s ", self._name, self._brightness) - self._brightness = self._luminary.set_luminance( + self._luminary.set_luminance( int(self._brightness / 2.55), transition) From a24aebd5aecb84ab89306077376b7c8fb13b9b82 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Wed, 17 May 2017 23:57:34 +0200 Subject: [PATCH 122/135] Updated dependency (#7638) --- homeassistant/components/homematic.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index 52ecb51238b..0ba5a814993 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyhomematic==0.1.25'] +REQUIREMENTS = ['pyhomematic==0.1.26'] DOMAIN = 'homematic' diff --git a/requirements_all.txt b/requirements_all.txt index b4b478bb062..a859a526305 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -551,7 +551,7 @@ pyharmony==1.0.12 pyhik==0.1.2 # homeassistant.components.homematic -pyhomematic==0.1.25 +pyhomematic==0.1.26 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.1.0 From f86edd4f2407554df5c6c3843da79f585094b433 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 18 May 2017 00:07:02 +0200 Subject: [PATCH 123/135] Seven segments OCR image processing (#7632) * Add initial seven segments OCR image processing * Fix typo --- .coveragerc | 1 + .../components/image_processing/__init__.py | 11 +- .../image_processing/seven_segments.py | 114 ++++++++++++++++++ 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/image_processing/seven_segments.py diff --git a/.coveragerc b/.coveragerc index 5b11aea6fd8..4ba57e0f750 100644 --- a/.coveragerc +++ b/.coveragerc @@ -252,6 +252,7 @@ omit = homeassistant/components/ifttt.py homeassistant/components/image_processing/dlib_face_detect.py homeassistant/components/image_processing/dlib_face_identify.py + homeassistant/components/image_processing/seven_segments.py homeassistant/components/joaoapps_join.py homeassistant/components/keyboard.py homeassistant/components/keyboard_remote.py diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index a37f1163b3d..7ca8b48931b 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -11,25 +11,26 @@ import os import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, CONF_NAME, CONF_ENTITY_ID) from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import get_component +_LOGGER = logging.getLogger(__name__) + DOMAIN = 'image_processing' DEPENDENCIES = ['camera'] -_LOGGER = logging.getLogger(__name__) - SCAN_INTERVAL = timedelta(seconds=10) DEVICE_CLASSES = [ - 'alpr', # automatic license plate recognition - 'face', # face + 'alpr', # Automatic license plate recognition + 'face', # Face + 'ocr', # OCR ] SERVICE_SCAN = 'scan' diff --git a/homeassistant/components/image_processing/seven_segments.py b/homeassistant/components/image_processing/seven_segments.py new file mode 100644 index 00000000000..07b9b9d5d80 --- /dev/null +++ b/homeassistant/components/image_processing/seven_segments.py @@ -0,0 +1,114 @@ +""" +Local optical character recognition processing of seven segements displays. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/image_processing.seven_segments/ +""" +import asyncio +import logging +import io +import os + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.core import split_entity_id +from homeassistant.components.image_processing import ( + PLATFORM_SCHEMA, ImageProcessingEntity, CONF_SOURCE, CONF_ENTITY_ID, + CONF_NAME) + +_LOGGER = logging.getLogger(__name__) + +CONF_DIGITS = 'digits' +CONF_HEIGHT = 'height' +CONF_SSOCR_BIN = 'ssocr' +CONF_THRESHOLD = 'threshold' +CONF_WIDTH = 'width' +CONF_X_POS = 'x_position' +CONF_Y_POS = 'y_position' + +DEFAULT_BINARY = 'ssocr' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DIGITS, default=-1): cv.positive_int, + vol.Optional(CONF_HEIGHT, default=0): cv.positive_int, + vol.Optional(CONF_SSOCR_BIN, default=DEFAULT_BINARY): cv.string, + vol.Optional(CONF_THRESHOLD, default=0): cv.positive_int, + vol.Optional(CONF_WIDTH, default=0): cv.positive_int, + vol.Optional(CONF_X_POS, default=0): cv.string, + vol.Optional(CONF_Y_POS, default=0): cv.positive_int, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Seven segments OCR platform.""" + entities = [] + for camera in config[CONF_SOURCE]: + entities.append(ImageProcessingSsocr( + hass, camera[CONF_ENTITY_ID], config, camera.get(CONF_NAME) + )) + + async_add_devices(entities) + + +class ImageProcessingSsocr(ImageProcessingEntity): + """Representation of the seven segments OCR image processing entity.""" + + def __init__(self, hass, camera_entity, config, name): + """Initialize seven segments processing.""" + self.hass = hass + self._camera_entity = camera_entity + if name: + self._name = name + else: + self._name = "SevenSegement OCR {0}".format( + split_entity_id(camera_entity)[1]) + self._state = None + self.filepath = os.path.join(self.hass.config.config_dir, 'ocr.png') + self._command = [ + config[CONF_SSOCR_BIN], 'erosion', 'make_mono', 'crop', + str(config[CONF_X_POS]), str(config[CONF_Y_POS]), + str(config[CONF_WIDTH]), str(config[CONF_HEIGHT]), '-t', + str(config[CONF_THRESHOLD]), '-d', str(config[CONF_DIGITS]), + self.filepath + ] + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'ocr' + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera_entity + + @property + def name(self): + """Return the name of the image processor.""" + return self._name + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + def process_image(self, image): + """Process the image.""" + from PIL import Image + import subprocess + + stream = io.BytesIO(image) + img = Image.open(stream) + img.save(self.filepath, 'png') + + ocr = subprocess.Popen( + self._command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out = ocr.communicate() + if out[0] != b'': + self._state = out[0].strip().decode('utf-8') + else: + self._state = None + _LOGGER.warning( + "Unable to detect value: %s", out[1].strip().decode('utf-8')) From a068efcd4775ca8c821dc16de73862190f89f3ab Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 May 2017 15:19:40 -0700 Subject: [PATCH 124/135] Abort tests when instances leaked (#7623) --- tests/common.py | 11 ++++------- tests/conftest.py | 10 +++++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/common.py b/tests/common.py index 5d9494eac81..30bd772a81f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -33,7 +33,7 @@ from homeassistant.util.async import ( _TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) -INST_COUNT = 0 +INSTANCES = [] def threadsafe_callback_factory(func): @@ -98,11 +98,10 @@ def get_test_home_assistant(): @asyncio.coroutine def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" - global INST_COUNT - INST_COUNT += 1 loop._thread_ident = threading.get_ident() hass = ha.HomeAssistant(loop) + INSTANCES.append(hass) orig_async_add_job = hass.async_add_job @@ -134,8 +133,7 @@ def async_test_home_assistant(loop): @asyncio.coroutine def mock_async_start(): """Start the mocking.""" - # 1. We only mock time during tests - # 2. We want block_till_done that is called inside stop_track_tasks + # We only mock time during tests and we want to track tasks with patch('homeassistant.core._async_create_timer'), \ patch.object(hass, 'async_stop_track_tasks'): yield from orig_start() @@ -145,8 +143,7 @@ def async_test_home_assistant(loop): @ha.callback def clear_instance(event): """Clear global instance.""" - global INST_COUNT - INST_COUNT -= 1 + INSTANCES.remove(hass) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance) diff --git a/tests/conftest.py b/tests/conftest.py index 07564e86c79..f1947a61ad0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ from homeassistant import util, setup from homeassistant.util import location from homeassistant.components import mqtt -from tests.common import async_test_home_assistant, mock_coro +from tests.common import async_test_home_assistant, mock_coro, INSTANCES from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -50,8 +50,12 @@ def verify_cleanup(): """Verify that the test has cleaned up resources correctly.""" yield - from tests import common - assert common.INST_COUNT < 2 + if len(INSTANCES) >= 2: + count = len(INSTANCES) + for inst in INSTANCES: + inst.stop() + pytest.exit("Detected non stopped instances " + "({}), aborting test run".format(count)) @pytest.fixture From 3d4b2436db0373d55fff5928f6c84b8f0a539a98 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 18 May 2017 04:20:59 +0200 Subject: [PATCH 125/135] Coerce color_temp to int even when passed in as kelvin (#7640) --- homeassistant/components/light/__init__.py | 2 +- tests/components/light/test_demo.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d73d21d7b3a..92db75d1e50 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -229,7 +229,7 @@ def preprocess_turn_on_alternatives(params): kelvin = params.pop(ATTR_KELVIN, None) if kelvin is not None: mired = color_util.color_temperature_kelvin_to_mired(kelvin) - params[ATTR_COLOR_TEMP] = mired + params[ATTR_COLOR_TEMP] = int(mired) brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None) if brightness_pct is not None: diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index 8d717b1d89a..2d3a752fafa 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -56,10 +56,10 @@ class TestDemoLight(unittest.TestCase): self.assertEqual(154, state.attributes.get(light.ATTR_MIN_MIREDS)) self.assertEqual(500, state.attributes.get(light.ATTR_MAX_MIREDS)) self.assertEqual('none', state.attributes.get(light.ATTR_EFFECT)) - light.turn_on(self.hass, ENTITY_LIGHT, kelvin=4000, brightness_pct=50) + light.turn_on(self.hass, ENTITY_LIGHT, kelvin=3000, brightness_pct=50) self.hass.block_till_done() state = self.hass.states.get(ENTITY_LIGHT) - self.assertEqual(250, state.attributes.get(light.ATTR_COLOR_TEMP)) + self.assertEqual(333, state.attributes.get(light.ATTR_COLOR_TEMP)) self.assertEqual(127, state.attributes.get(light.ATTR_BRIGHTNESS)) def test_turn_off(self): From e773133bcfa9f35e6eab3c2f5c44702ea0fcfd3d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 May 2017 21:57:50 -0700 Subject: [PATCH 126/135] Fix automation failing to setup if no automations specified (#7647) --- homeassistant/components/automation/__init__.py | 7 +------ tests/components/automation/test_init.py | 2 +- tests/components/automation/test_state.py | 12 ++++++------ tests/components/automation/test_template.py | 2 +- tests/components/automation/test_time.py | 4 ++-- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 19d542628bc..9227222d479 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -156,10 +156,7 @@ def async_setup(hass, config): component = EntityComponent(_LOGGER, DOMAIN, hass, group_name=GROUP_NAME_ALL_AUTOMATIONS) - success = yield from _async_process_config(hass, config, component) - - if not success: - return False + yield from _async_process_config(hass, config, component) descriptions = yield from hass.loop.run_in_executor( None, conf_util.load_yaml_config_file, os.path.join( @@ -418,8 +415,6 @@ def _async_process_config(hass, config, component): if entities: yield from component.async_add_entities(entities) - return len(entities) > 0 - def _async_get_action(hass, config, name): """Return an action based on a configuration.""" diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 71f9fb83b65..f67c572ae75 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -33,7 +33,7 @@ class TestAutomation(unittest.TestCase): def test_service_data_not_a_dict(self): """Test service data not dict.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'event', diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index d65ffcb4d4f..cf715bc5e32 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -245,7 +245,7 @@ class TestAutomationState(unittest.TestCase): def test_if_fails_setup_if_to_boolean_value(self): """Test for setup failure for boolean to.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'state', @@ -260,7 +260,7 @@ class TestAutomationState(unittest.TestCase): def test_if_fails_setup_if_from_boolean_value(self): """Test for setup failure for boolean from.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'state', @@ -275,7 +275,7 @@ class TestAutomationState(unittest.TestCase): def test_if_fails_setup_bad_for(self): """Test for setup failure for bad for.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'state', @@ -293,7 +293,7 @@ class TestAutomationState(unittest.TestCase): def test_if_fails_setup_for_without_to(self): """Test for setup failures for missing to.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'state', @@ -475,7 +475,7 @@ class TestAutomationState(unittest.TestCase): def test_if_fails_setup_for_without_time(self): """Test for setup failure if no time is provided.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'event', @@ -493,7 +493,7 @@ class TestAutomationState(unittest.TestCase): def test_if_fails_setup_for_without_entity(self): """Test for setup failure if no entity is provided.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': {'event_type': 'bla'}, 'condition': { diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index cf8b7a59c87..5cc47687665 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -370,7 +370,7 @@ class TestAutomationTemplate(unittest.TestCase): def test_if_fires_on_change_with_bad_template(self): """Test for firing on change with bad template.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'template', diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 738c2251264..3489699d588 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -207,7 +207,7 @@ class TestAutomationTime(unittest.TestCase): def test_if_not_working_if_no_values_in_conf_provided(self): """Test for failure if no configuration.""" with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'time', @@ -230,7 +230,7 @@ class TestAutomationTime(unittest.TestCase): This should break the before rule. """ with assert_setup_component(0): - assert not setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'time', From 6d97546f406d7ef10f623a37f67281a1ec601d4c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 May 2017 22:29:46 -0700 Subject: [PATCH 127/135] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../frontend/www_static/frontend.html | 4 ++-- .../frontend/www_static/frontend.html.gz | Bin 140708 -> 140775 bytes .../www_static/home-assistant-polymer | 2 +- .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2512 -> 2512 bytes 6 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 3dea156f6ed..e0fd270b81b 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,7 +3,7 @@ FINGERPRINTS = { "compatibility.js": "8e4c44b5f4288cc48ec1ba94a9bec812", "core.js": "d4a7cb8c80c62b536764e0e81385f6aa", - "frontend.html": "19637e5a62837c8dc0bec1863adc9249", + "frontend.html": "fbb9d6bdd3d661db26cad9475a5e22f1", "mdi.html": "f407a5a57addbe93817ee1b244d33fbe", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", "panels/ha-panel-automation.html": "f9a6727e2354224577298fc0f2dadc2e", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index ff4ce4ba979..c0443749f7c 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -21,7 +21,7 @@ window.hassUtil.DOMAINS_WITH_CARD = [ ]; window.hassUtil.DOMAINS_WITH_MORE_INFO = [ - 'alarm_control_panel', 'automation', 'binary_sensor', 'camera', 'climate', 'configurator', + 'alarm_control_panel', 'automation', 'camera', 'climate', 'configurator', 'cover', 'fan', 'group', 'light', 'lock', 'media_player', 'script', 'sun', 'updater', ]; @@ -744,4 +744,4 @@ return performance.now()};else var t=function(){return Date.now()};var e=functio this.hass.callService('media_player', service, serviceData); }, }); -}()); \ No newline at end of file +}()); \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index fa38fa165b90aab8600aacdec519ac95eafd48c0..edb01a40d53953905e3727fb0cd4e366ee2e5893 100644 GIT binary patch delta 94990 zcmV(#K;*xq%LwPo2nQdF2naGU9kB=2uzw&_fvi%d9BE>NzCno*ckW)KRP7QD9V`5? zKpi6e06gQr_R#FnRBNidUd^GSa-Tu{{QB(2mv7&_e)CtFUjteTM2SCNdq2KBdEP{h zJxdDz8s5gV`P%;J>CgZA>(hUI4z1?thwKue87$KLa-CkXsxyI}Jn6vHJFzDNSAT_0 zU01C9N#uCCPNfHCa$GklA5JEbP0mygKqj zSAqR+)rE|X(X`6t?cx92dbpj+E;OiskBzI9y?{K-i%ve4jDoy6BvDO;~-?C`vUZv8&*? z(@|wWtn3V7`Wj82ij?WDX>4GAmges$P(6i-wa|`bqK>A~8-bxYa4w*%#(#b?5Z*ja zC|}K5I2JP`>%kxd>yo1uV^;XCI4oEY7$i=pHw*R|Y1{N z6Rp*Y70b_f8KaSw!kBOdbARCCS&i<_9j8*JGZp9fmG+trzM;EYbtuRu^h$wZ1@8dC zO+=1;mtJ3+^Xmb&jX^;TRtfz>i%`ezX3&`C`E{Ny zvzdr|%pokT$)?b~He^jcPUjvd_#6c+GdM5>CY8j%9}_-bsM+RK27egbox+tO9^JG- zonGSVrdhSXkaH`Wg|6Mojj8qZYH%H%V+Ij#6xM9T>RCmW=8>!glwBKOML#BoG;oP0 z+UF^pzcl)xh&#?xNSi{Jx+0}eENw()<|u??fZ{lOaAwG4SxvwyNcY(lo4v>5 zX-9DW4k`fXdbecI&2*XQ|)aA8+NF81H z6{oJ)y_?c}uZIjI-#cA+d4nj%}JfqBJbR@z5(Xl+n%`LxFiEu#o z6vflJuLU}~L&i~mo?T|O$k_0ihH28=@OO{inh5dYd@hC5yL#&Hs?P1X*|Oqx&CeIi zjj=>q=SLv%rhhR%&dfM-@|o!U-Kf>%-iF#OJYpMazmV(=rm`EgF5xyw_A}7=RVY>Q zAQPKzf6|<5dyjN7V2e71BI*`Tv*Av^qlzDPM#JGy%0@!%sSm;eu`kgSoBkbt{+H-n19aepW4Jm&34J10o#C7q+t5S#e0 zM7h&!s>LR2!2Cv&l7y_JcM2M#(kR1_6x&jbz+N-L;f`=GkbS|g5gzJz8L?5=nUGlB zRl?aHbc=_Hf>?=PTLInzY!k?jjJ>~WL53;S-Ih~Tb54X3=;09QkTk1=6Z+;0gT9aM zBS(dmLw{e7-~%{NizbK;htJY-{_c9U`z(u7>pNGj^YlZSEz4hpFycox>VIGH9GGSwl_ARlN9I&CHSxaM6{}HyQZ=gK ztIqaG;4Tx2ezjsCLAaGshqMp&;gGG+&q=q>(_$-4+!$G%%o(bZ&d}Zad*&ZnIwQC3 zc0Ifz@fF5&t031z`ihDuW1J8w^2)HzGk*;0X}54yEEz0BAk`G0@U?^`>^d;bm+Wj+ z7BdDlL9q5!qeA_QY=N(oxa(-A6}lWb=RrKZP0Y*mBR21;Cazt)NFoWKPpj{Iy;!i? zofhj_#@9rdUia}GH2Y*%ja&C*+kZPb zP<+Hr%I3f&=#q*5Pw;{Y-FW0VU>HG)b+QdWUf>fiKR*F_V>v#d0yNsSfNnlz%dGZS zM2{k@U!@mmnc4MFz1PBBM3z;YU&n1(8Y{d=bL-H}otLUI3^gaTg-$ImC#PEM+J!1x z!uZZ;v+~wx?lgGi=_|?olAS|S`F{*Bw$5{Bqv5-ye>97lMc7 zwQI@SWnV2MUksfCMzf7CbC8!}Mn}^q+!*%W*ihoifVem06 z^R8Zazvfhp?b(3j1q1DK4 zDK5UeC7N%@Y{op2O%r;;hm^Uzj~v*1$#)?pWjtQM5a7jTO82m~=qGqp_QAtcGzb$Z zOJIO$Aj{fGTXO8q0NVwNcDwYY?1gyOBsTfoujpl*DYc2MoX?9yD5;l^AjA@9VVG zjj~S}$!@S8SZ!8ls=l+u<+{pdeb}Fvg*KgRwfP}CFVnryN06dku76BX;T20+80*_@ zUZ$03P3(GTscWUc9>)~Dilk-nem6OMK#!c6u`|m}*29z9+W6daL>o6CF?tspKswlm z1FXc+@e5eMWm>{^?xK!579-{*Y?(^b+J-r^iG8I1Hbl`_wjoNJf%ui6?QB}BO&SQp zZNz7%;Fhs8LU>M4(|=_Y#PqY`;tUTAz_w5j8h?RKLn}0>BcKHH{$B`FrUsM4gPH%| z|ND##D$JA@cN^v>{P0JI%_Y-}(m<4M;1v*5OAE_lU9tWLj831|hFwaF<$`^#-!X~q zE~-$or(r~!#fV&vW_w#KOtk97R<{gfb^n?-=5&j)%Q9>loPXI$8#i6k&W}(3^X&jm z?ieG@(S;3w4To30DNtgeU{r=7^la9VzDd)RGrv z#`5{%`i$;uZ*Tu#U6z`-;Km0p=RRy0>QANmK0G_Tj8NwuZb6m>>TaAh?X0#=IsFoi zH_>F~ng(l}0)Lys5K^}_G27XbUe_(X_L9zjcmCyO&*nD&Z7=~ZI?cxY*vzVD(*40t zaGD~~*wyqa2MHX0(Tiz_H7iOs(0&xu@y4jhPd8hq6mJ2s-^NO~Qu}LL-wRQd$QC^n2xQmNLOipRWfu#RDa;E97HP+w$K?JtKu63AihCs z^O&!LO(e=$snLgYv1VdZCLkAYd9mBuo6Uw-;#3?P@ZpP#tE?gfg>`vt_!1b7*<<<4 zVa$3x_4?|RO~+J8z;RkmlYe{r`sV?+sL3v_b(@eEZZ>4Dfln|14t&>%pv)Vv=&o-z z=*D1N>VN)A*?$WCm(y{I25Tk|_yFrP6@bt)l&%G1It{~(@^!EYH{GqK_s`PB;^`{W z?`2q2M^#irsd_Wh3l7s&b{O;ue7B<5*4wvdC(mEK=Ed?TONs$3imdJihe0$7PlwYn zm-4z7Pd8Bt9NhPm+;2CsX6DT`%R8BZJ-y#<`$qpBE2`iA{|&8ixX;B^NO2?u~V z&et5e!nYP|5a6Sv?F|+JgY-D$FRy<3v94F5-ElrZNwix#KKgg2?aO z!G9+~wJ7)_13puBgTM5DeA-WEsI(2t()cj1$WgbGG}bIWB&vX=RO}sWL-a5THFOzY z{fGZk(JK)Ci1v*rDf<)Nk@`H40j;arq)fZ*P^=H89*TS@vU#WI-Y_&@iUX}7q3RuM zsUuqmBAmE&bw&XP@&W&f&>c%$1D0jVlYh|+$h$2-8)EvF=8WqQk#CK~J@| zy$ix3RWi|NHq6O^KDWVZ;{{tzCtcr!3iHoCUOEO*V`9eWkp!Pv{`&4k|N8)^8HCe$ z*WpqrzCIFJNRS3Y*cDHr%;21O-KTpN9z&Q%rzC9&UiU1Skr|G4mGgA={*t@wYJaQf z7QyQLs(ojlx|pgdRC4p}6;&yt9^Q17TWT2jSb?V3D$bo_&ly|wfeYtw2u&XihbA_f zrGYbLmqq^pFULFFS$f1v!@%F~E-XMmevsFmVyi5F-%pQB0Cto$yWdgtzS*pQy5m1y z(El36;7le8aKB^YHhQ!Ay)4;v|9^*Ow+^Yqe|&U||BbiNMf}Gf&hfwT7E*}+I6ue# zBtlvS*#@FjM~_&c#K<=W?9FeC@O*~<*@fHzKD>|rxn->J9nbJTC_(`E=*G=%l5O#lL`eRZ8-K(JK{61K z22pX&wZn06hQ*#;;Xl|wOJ3{G{I1k2 zjJdij-g%0$Ps=jB)*XPzc~w;PQ+2)pk(z8ZFfZbc2d`f)R|_qQGLnl~CiF@NEI(SUQJ7=h6BGMXGYb{Ji%YCS9Al9mjA~uzy))RWY3dIeY(8V*^ z)jBV!A6EW5yVl)A>@=I+-gfaHpx`jF(UD+!RP!?UPjWgHWpJXCnt!szSXA@>fpDol zA`L5md43HAtl`Kf^j61r?%3aO)PW&3@a4g&Jx~^Y9(Z>3t<86tgDj1gkYKetY#|%n z)?Teytwb4~f`A;dJ9kJ@9m0vb#&_v}uB47q?rN?M?SVOM@HGYqzjKFPAJzJN2j|N< zL+gbO{^k&+xF$|0wSSBK!;v2KMqXM&;zs?uL{I)a3ugLwrEyt6!VI3OFXxp;g&6*r$<6$lUifma!W%A13 z!BzYk2$S~y13hd&X%J3iBy6H{$LMls&9NZm+1)o*fykb44HeVENjJbdm%hk{lP!ir z3_@C6;+0=pS%35}msScnv7h}NB%Bl#ylSG1og%gASoDw_@T!-z2D5oq;|V%CFw4Nu zjciYg2U0VrXED6JbzmAukf0v4*qC_F*!GyeJ9qXdG@vD|o6Xjh`l2Y8;?bmk z0ivqdS$l$m>y>BamhA8A3`z8+ZRznxlGwAo?lw_c*MI5k%0qP*&|+6O-G+L7@y50< zR|JyKoV8RoI+W~8r?fV$cmE)|snZgOgJ^lP3#Igalg%Bh;zng-cE-!K;Hib3c@H0= zCB4UTllaQtbPK1ncV?U26?xEp*m2mGoPWu` z!mX0kr4J_~36CiHScKDz-Vb*Zb8@oJ)JXw? zSbW7hW8tLBw0B8;3mg2}gI4LY&1Hea-*Z;NpKPLx~vaLly(1m z%6afev;bN6zo(oZ9`ZWF2RiGAhhaQN+1Ug&gX31II7Eh$?*tun;Tg6J<7|LiMz(;{ zp!lUq=udJ@$LqYDkp%Sa zft>|>>!2P~SJ_1kTX~WkK)(C}dV{=KT`p9X_~1F|Lf7HHc{)n&DnopP8rsU>p(BD! z5cmynPxuYrb7ui^YMMVERuTF~uf+$EeP8RQWxNeO6|;Diy|L=s}7LykSVAyFev zjS&N(m)Wu#$|{|JyUg)yFo-B4`@aq-)zR!OXDx%{D zSQ>Vwt9{}W2CIplP0bVQv7xG0#tH@n}o95jXK47-Z~ExKrK z72D4i1V$66t>XGR-%>4|&tHGcH2_{Ep%<@Q!`Cv1$WSkP`{k0I)pgYb-PlBy_K4kQN==cAE>yxm1z6ZP+X2yYIm z+jRVcpgS5vUK?ml*Tj7sy}0tGF-o#9`BCOP{@M4oB^8KmgsPjbb@T5VjT$Kc+Mp`R z+Pep%KUr%>Reo1tPJe1Qb25zcNq-cNNFQVY?vNdBrCxM4!4tdoG-=Pey*-szBfe}- z9jDN-ngn^l(O5d4#Z-8oS@l$~F{vcj!iC!M8vWYb_s$*D8C7#uy}0DEwg)fMC>P0v z!Ih{xG*#y6UYI6s@z&t`0-qi*BNpZI(#p;m05w=620F-%*?(xove#HHs-ry0+Q1Xc zD->+U1Ano?$s_8HkjO_<;Cu`nm)GJXZo>+lA&?DhIaDnT3%5xFKqHiIBy~}tDBWNG zQWDX!d8k?QaGXDu;`KO}R)Gj{KE)wa{1*tu#H=%bMc!MUftY%ENmpZH_>hfr8+m#Z z>>UM+Mm75;D}R?D2(sk*86dhb9d7ieh~4|1Z&GK|Uw)M($e;vXkGtBV(9J1!cYanAu{k7-MR7sYH{wQJYBS59q; zF{xJC-I$>yla-{NtgZ30VljW0p1;I>QFOph(^9g0fqx_U^I~1fDddG-h>6@P(+y_pvQo90=QAdu4Uqqzy>!#|;+(} z#(m&`t8Wvo`U=&3+zUQ72`9-Xh}&>19&>cL8nL=c;Z*bn-75-iY`R^9Es$)MIfopJ z&EzLMQGcBUMZk)%K32{_@XfU=dBX4{e<)>dC;34g9)ZP0O3Aeb4s0&8;P_hfZ z?|(YQ1o$gc9PUX>M3ReOn_&CF_ zmYBJe0GK^7$Dxa@S7xDKje(revpHJeZ&-ZzH~Kt`jL|k0kRZE>5U&9dPsMvx!8Q3n z#l?uY)}WK7i71bu1V|G6$VT)*1cOFNOn=_B0xS82&o#0V+Ci98u|L7 zMm$6nH_s=`(3xww5#(`mtE~6hD(OC@Zb*X>m$+Xp$%RVIY|-0?C=>c$SN}xViGP?l z9CA<53%BdXG@Z3op{8(!SzR}E#v9y3G*kB0;2|)vF&cXn9cJ6vq$@|jXbzCtr746K zDDbS}y#gg;Jc|YTgcFOKSdxk`p=&_c6j}UNux<6_EVSUnBLg(%M2b%n-+ac~n{OmH zm*~09bOe-UPS?G-HCncBYhC4ew0}j4G45ZTpwZm;9kNynwliR~S=bjWIz}sJJ680# z8N>O!DAAA|oge^%&+;o)X0?vC%q^qGz8J|mj0#@7>chM@ML|?@mJ% zi03C274Y+HGuw(Y; zW>I&$n{fMdAyaP%=Gg}#Io)M|QKz#HI*Yngse1>f4sVPbcgcx_{g1pVAcPCWH}(w# z@&;8Ul+KIriSU%+8R8_B{eOccbeCUdSM|SLBdA#OjsGdMP%V7 zMFj-xfR?1iS$+VhlBYZ@_e@U2z7@pTP|wOUf=mNx6eZsuc(BJ)ewU`p+qbWvCXql} zP1AV-t9w%R>6{~=lN~ zK`Ni#dUyCoOIe=D3(|23<>TjzbcOrBkSxb$MO6q0udF9%CxcVj4f~$#NT>a}q7*!0 z^b<2#bOz)d0-M)>s2m4!v$aXHZ;@j)WsFHhG`{f;N|ZVrv43M9C|hc)GEQA{sqWRc zDAn^cEwXs)8M@R=7q9sa*)qL$Azo4JsV8ifUjGLJF{Qn_uPASX?}%ONUPrOGR}9VB z1mWM?k{7X-ntYwy+m;#S$!IX*F_D*9-qn1p?y=BXMq%#(tZXRV&kxP?dxIlb(i+jt zxjL+1P3QNzqkld%zXD?Wei-$O&|bpmvK=j@4fJlKTD9y2`>dneYi49N%m22F{FTM-6T@j1<<9pNfHJmFNV+MsnyHi&wPxcrYBvgBB6elSH>j zV5OhlP=-B^vZFVQf*L)oU&(U*ex4*Ato)vipK|dUuYYXNnI`VM?b9p{*B-2h$?}YY zcO=De=;(zY$gUl89RU{lWA!^<+|C+wd&=1uU@WIFV|)-*G4Z{^-6NlHts5RpM%CUp;p%;QZ){; ziZYU6n!GP;v?+rv4U2e|RKW{dIdu&ex%+N2KQP@&oNVd2CGreV`3` zSua?9iaJ4$;g2wAZ>IJ%00&>UiKsI)TWN!#LVpM5P`O4kW&G=@R6E6~H&r1Tg=jbl zr1W%VzizGN)%JGW=E-)9j<+2hywofND?xT7B#KKiLCl#&%(;(fO<7piH0$XlpcgY)6*~xzYUfKFy;ZV>`0dK}|^EQmalYJu_DNS4CRnFeyKb z?SDt4w%)=m%cxi--EFw&xATth@CcXKmPLy5*w#Ev6V2*iqe7mbBO3MC+Aj zMT^Z}ZCR2)tgS;VKxMP(dn@VJ9~j~LLMt$#N7bO!t^8fJr4Wsjjbyx4_d2y+U)#c# zWeXy1ItND&Hjz(=^%nk~U%S#$YPDYG`G2k1d_en~G%GFYmRn)}6}Rs5s#wfD7t88$ z+PL7AMfGiXUS#?Ec3bTh_@u23mr~hhnb`0qnPXT(!YEx8ImK;*y3MW&-y(mWbDs#@ zFu!@}3)_*n<8n*z`(B+7-+p85(MZ@@=mju9QxtsvJn=xCxa%lH-O}riurSa{rhgj_ zP}dv#YC7GFjZ?5)a;%=UGdwKOQ-xri{VMoEU^vy_1;psr*@+Sdwmu7$wu)V!1!P-y z+belN@Yzw)OL$Hs)m5=x%>SLS)l*5EhK(dhyW{#X-xm2&)JENPs4SF39@^P?j#@r@ z?|h&-!!)97fJfhM zJ7(p1HyfBrF{tO&(%iXokbilVG?tBkSfZIQQ1>vx+goJ?Xb(ePoRr+RqN4T}7_z8q zn2d@IU1&Z$>w+%Mon5xMRTvDdYq3I+Ex7}?!?1Vc9j#gA*DFuyUk&!^N<8lUjI^9Y zM?pf5W-vg32F{f_w`2Zbf ziYk@14U&VhRsfUoa({E*ux?hYnYNHb?qgbO3$nBaj3zh+);-nQuEDK?9=2oBc+=*} zn0xh@!|7xd&^KHS4y^Gmfy5* z;Vte`P_7RKXypOe3L@s%vCqWA=m6hUz)`VV%YwIXKPJeA9DT6~qZSPH@Edz;m zPvCU>q;K;gv&DzVettCuP79jpwNJIEF|p>|nG0Tod%Cd7HTQjh1(jg>4 z;5X6Bats^Q=YN)ZJft00I^@Mi^z}YlmsL^5!oF?X@57wT_;Isu_uogApXE^3%85Fo z?^mC~tu3H$;`UZ#Zq(_Iu%$nt!|iKT?xG-v$IE4rlh598Od}Jvb;Gf;1WZ@`D@fz6 zgSqQlI{R={hBPANwrkT+VG3>f4zeXipGsA+m>6()BY(ng#ly{jSG1~a{wN2b_Fn%4 zD3d`M@mg33a(rw@efa;g_vYViBiX^|@A)er^t6U(fix-EZZ~OIpW92?FLv9Xt)5Jx zqoY71Bq63q4guNLNc`X5U23laNZOuE&O0xYwusu7TeoiARjLtgR`{x%b+>8!3h04y z7Ty`oOMeB|GZ|s*&Jr5z=CH^Ob?h?f6|5Ru=A%{*&8jk>6h2;_N9WYo%4GEez|3t} z4-@Cx=`X%S-Keu}1tjEQAo`6~E4df#C#>lCKhV!++o&4f6i#%7jdlWy^A^~Sqz%{8 z$w=z5_8vAX@RFJ!RO%d0%4N2!rzc578_7Dr|9?{(hY_X;fR4osPi|Y*VcjZ-1<;t_ z)<5fF6v)yRI=7HwDhCV2=^=nIJP!94U!*4WMjNztb8JOlY(AvcY-v>U4BSH)4es}^ zFq(oM94C#wrjlMWdA~=Tff_E<20YjD7Exs(^Dafrv1^RIZJdI&JKLh<-jkd3{ z`hP#NyN|^U2UU*Ty0`rw16t=SAV>YmXgMdD^Zf^t;v!k$zBZO0qyxA7>i@KNy=j-5 zbmcAl?Pln?4dNVN`?gkA1|axop7G+rL#1HYf!kb*bsaL+uAWG8RNg^yb=AVGA3*3Bea%SM3{`fNtbYRx zy{BSZmIgVpeqXH3>^pEG$#$Fk7HcSzBreyCVi@q$G)N1mr!5Vm~jAhz<0oEIvM#4w#?GdYIFZ zj#VS+T>Iq2Z3huiV?&r}wZshw^M51bUw3GdQ4pT`7Iy}23cAOt? zLbS(;aOo)7jpP^g<#e74ZO^Z0+io|lS@ZdPI-f5)kM14(1dpBi$NinG_DVPwQ%jzSZzl3~1h-Yp4RY@fm2zir!06pLJ21rZdhJ-K5SYF% zZecreUYl5&^LK?GqGA(w2Cag^K;?kI(Bhs;q@xs4Y)SUxL7QxBy?+OTjfShLZZudu z5<~Lr@Wbts^p6yt_-FQH^I(VUxyj@@+?vb;#O+if>g_**m+|#kIqsene=p&_?cGoN zX>T7T`Xkj45{AQDU5_b6ov#o`cYm_53igrnLjU!n<;7Xf#^Pyja6d`=5nO)J13zyc z^9~>#^SfBt%-&CeJ%1l~J!1>^TY7%f((^&6=lkKF?|VJp56?^WJm@!cy)TB0WWgp~ zKlHkO*wDQ0yKUMJw&?qj*Y_jYx7z~uPCI&EfP>%v1KIz}<-x7WHKu658}>tVRo{q|aq!nGc>*BVe~YOk7nLj?CwP)eKa85iDAo(d1o z@$`C9tbrD>*?($wu;cUVy35QfgR>&iSr3bat~0i2BDN=ohea4Y@-pPJtQ zNgInqH!g=FSmn-z_!64Se7%6x!ANZ*AuAOF?K@%zIN&4LzrT0UVOO^*kX#pnhpQ@( zD2C=`cnOXq`|n-x7?upBVejYSD(XFef7gqaodOIo$$xgTDjY zOO|y%+*y}vcb&>oE{*IP*+KG^J)8rqHMDQAl?*1<@P{JD{|T<>5BVefpImtqs0AfT z;og;(QroQ$b?G7rO$jC|Q>!b$9TlxBIh}z~Ladxkm^kto*2KcG>0aAXh_}cyv1v1_ zvEoH}&wsmkwf5B~Ok1(z!!-nYFVIwv7oQ&O!@wJ_-}00XBIk0pxULQ7Fq>g%sm>A_JgmN=DLAG(cb+4%NvVV4DTG&j)vS~Zuxjj#6Rd9-3iC(jm zimyW9$Yr!9yFs2xj=fOOIcm&Uvqx&>Bs$b<*()o3ITn-1UOj(A1Rl<3K(_8xgsc34 zB}jL2oEhkX-6F5hyucZ=;3GQYZDjbnDM7!k8w{vo4CdO1)dgPx<~~0*RlQx<5boF^O2~ruXxMpbN@CDFHYK2S0AKDr#^o^7wCCmG99k zFPCv98i9?DQ5f1(R^v9C_wrZmbhv?Og9gS{6whXxDX0y5c*gC$&8&jeHl0h`eShDw z{z%Dw?b17Sd8D7psyr*FNLxz>s2kyO`nMUEugk`ut$2sQgZDGx-+Wo*&E;i@fc}zF z86P=92=!R$H{XUXCr2oTGNWUIJ>+j29z(YYnwjNGa;C|Y8Gs3elOt*{C}r!yu3B< zP)Q0&dK&4YWpN`5C#J9>;`YnfKmL>5*;%z1`3h#L zK}*5y70%=tPnGp3%#oz|^mBew(QiskrDBU%+w_!{;W|YA-==`N@8Cz&zpcK8z>rH*FWSt zm>1Pqw#40H`w<^|ZD(;Xzik9hCzH1`YiEOlQ0E%fHaDuodhTKk<5vB}7Huq(5AN=1 zYP%QEpx5Zs$py*gf_1%oIbV{-Ur<>Q6{5QI8yzLVwI)K>7mZCy#edgZgS{@&RN%LU zswzUA=^JZJI3|wAIiFb$xmMnpYxaLd1wYU}&X>V8;LBfM-Y&Xf>6b@wOy$p%f7pwVw$vA zj?|g5ilhHW`ZFKyX8K+_|?v9nW4yr9qM{Bogh<7NCqV^$0pV{$AV?;wg9-=ryTYrT?on+ty! zn3>lPm+^qb?AQcj8Q04jF5`&QI!{R&$2*xn3UhRqq@hSDaG5_fkZ;GYC`E`yrBEsI ztz(@a-xpW&E{@nEN-e@ijwph)Sx7m5nrVj^B8Qwz%^E`suNgNF+=*mnMD9|d6#HC~ z@Mdd472a#RWq+pD8ISr4*VdF+Gj6~_onPIKQOtA7HERN{(d_Q758DQR;u&dklaT?1 z-$b9*tc_0nk&}1xsObAN+Y!3We#XGm+b2+~fVwe;F|5V)FjK^WaIH=sD`#rX3b=mo z_|O>z#QaAHr}9EL)IU27>jDSJoqB2xE8Cf^0-!1Y!hgML0!~^*GXcyaC!RWlQWJg^ zXU1LO@KTzktWl{j%xQsvn5}bsT%3%Gy}e{6I^lUXy@yPfThLlsra5oIbrxF>-x!@R z)kzMx;d(ENBZoN|Cuu3O^ous;;Iq`=lt*dbgZ$osVgSx6PIpWglwH*ru*B9a#?5T3EcQ&8ggq{&JLs8p!PGGwt)p$vhmsC#GL!L?4 z!S1L;rpRG*tW1RTbc3}a_qxsHq`T8k z&~DCXB>M;KP?PJ!5Y#2!RvJDg*Y^br)7i{|(*S(&#DsN)o~ogIayB^K7ep$m9)+c7o$IVGTNlHe-j@u@<1-I)-pt>kMr4c zz@~XUzNBOP7C%YXtE1u}pQ7G{RVtGPq&g|}lLV!1rM(22<$V6-)r(|HOcgjl9FDhf z0>0sE;$$T#5U6Gi$F6nyjU2K>G0+7DZ0>n3&V#$AM;m=t$^X0 z@T`dxqOxWe(fm9b%gid8&425NR6UDHPp+Y{GXXT5tQvtLZK@L}6PjwqQkv==43w^{ zz+rrpWd$1NJOdie_-Yf1_Lv5uzz0$rRdZI`$Z4;1~YeeOD4tSE#!LFZh|J8*MFxGfPu?wltZWn zU=W@rS3tBue>miEy_|}l`Lu>#%6oUi*7XG<y#@$Bd<>z4V{YMoaolbW1NdMBpvY=;{2T zoOMz0)p|X%eoqy69sAxCw6Q7;0Cj>`@k>6Ik>U76y^GB^wtw#%c=f{qhVgm6oIEea z^Cj+qV|B&Wr^;U8;o{56_MLMpN-&e>YCA@j!I7{qh|bFzman|Jh^pl{ zi|;Wr_Fg%j&wr|W=Kz3Xv}68d{|o%rTfkaJB8XZH3GtKYaUNX)>6XPmutLmbIZ zgnSXVC$~l>*TnF9MWDiv!hAJiYE+l=&mUN+jnqSe{58P?bT&4Y{wWEfJ zSN!CisPyU4&=B7Np=T(1V zr>SKK?Y%ZJ5!3HoTGzlEST!A=Q(OEzo{-drNqi; zexP}0{(rw)@I(#lXFd5Zk$rCkA4y>oX9Z#1cW`f)Rbl086&&N~&Z4nrZbkisHvy^X z?QTxHF_4N`Nbw`IS`Y4(Sjk4!A7ekAGGZ$x!&vgN`)CApLonz2C2`~~nGzc(M6d;T)@PBGn)(A()6L5jcV&Ex>Of0t!aOyA? zr6auiW7USS7Dgkfc8xXz`q17*@A-sGL!n+0=05(ZXW=qd5r>Hw9L{urd`V~RtS*rB z&t{UcK2)@pwU9tZ&OFjmXj7QbD(NXR4RBPV2{hTFOQ_9ch(FFAi3tWSB{@wMq1ir0 znSa<_X0!YT?dg-IR4e|}voNOgJt>&6UYou8vY4664-`i}{Q!3r%n#-2mq+uD#VniQ ztGNAmTWepU4v0agj)dNa1_~9YZT*-Er{W+j)N*EsGR&U+!ZQE^v2?#Z{r3;O7w_Mn zK70E6?{AMzpS}J4_ZQEO4hv^r|Ck}Q8-LaFNQ^EXhFW>``(K{Ee)atH=^sbGd2Pt& zhq|MOelH(^1_WXqQ-|<6!BuXNbQ&Drt71~-T@r-bh@6t%Ag`%tig9Yi9yK&6scI8f zmNGRWKfj(ftCg~H)SLe+-Lu`cssmEX(jA+vXL$8DS(m~?wg_!mpf2d=A4@6F*2Ts zRAZ#D7;fL)UEIMZVR76)xx0fmAADl8@+3C;6A*ue0QCl8u2ZOo_hcebmAQo-Sr=tc zW=2>B`hN|0MQ#KCO4{W8_$FoT4}a91@LH`@*6~Sdq&$O{D%yKx#VQRp1=qm5o)ckx zdr{O)@OlMTM0sd;g^{<%g$yUBl3``x-h|T;n7mil4|Z86x1AkgB=a<71&s-yiBAquW(M}!^qnPV>j7VW=FoI% zl{gpRqmV;$(Gu3ejt3rGi`FRNr^OfIC{11?`Sm-7rLdQ`q4KSK`V9xGmFsj@sQ%fT z-*JF}Jn0H2%d$vi<`Dt;Ql2JnUC^y=K;kH_F*wmL%PG9^5nu%S^nY9>nAE(y5G3wK zkya2(OW6i0A!*cYYd$nl38!*>GV2q^Qd#5R1>tlxHVVi3hp|-9vQ(0HR?xU3?VH_# z?pA6<4%LoX$%X*eGm(oC!>f*6L4|@05EgFt0^>Gb5d_-?UT(&t5@zsr<=^jw$ftpE z%}p~`Jgu`1yOuV!?thQwudw5e)qaw62YW^GV==P3m3p;M;b^Xvb2qV_LEm|j7H9>=@<+%6&@8e520I-q>kAEy&VL$+4g%mjmv6`CUTilw3 zl!VCh9?+o=wJFJ2_Y^QCEkV$qzY#h1l!7;Un=MM_L<{A#!gXE-p% z>D&Sv?`voMDPuv8pij-xG^Rw4p`~-mu?Z`6`q1OIl^=XaC>qf`Qu0j0K-?oO1XAvG zCLq~7=exTcM1NY@y6Db`_zN$Re^J6b^wk%@xI*CXQv%0DZz@0FJz>r>+lo1J!zn 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 \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz index fec3a4f832bf9f5816f13564203a6ced55ac4e5e..86b8d15fd5d715f1bab8e0b4dcf6fcfed41408f4 100644 GIT binary patch literal 12987 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 literal 6135 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 J;dTdI001U%0Sy2E From 4a3d9a956d7f778c40e461be30b137c77df4c6cc Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Fri, 19 May 2017 02:39:31 +0200 Subject: [PATCH 130/135] Final tweaks for Zwave panel (#7652) * # This is a combination of 3 commits. # The first commit's message is: Add seperate zwave panel # The 2nd commit message will be skipped: # unused import # The 3rd commit message will be skipped: # Use get for config * Add seperate zwave panel * Modify set_config_parameter to accept setting string values * descriptions * Tweaks * Tweaks * Tweaks * Tweaks * lint * Fallback if no config parameteres are available * Update services.yaml * review changes --- homeassistant/components/zwave/__init__.py | 40 ++++++++++---------- homeassistant/components/zwave/services.yaml | 4 +- tests/components/zwave/test_init.py | 33 +++++++--------- 3 files changed, 36 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index c49983b3178..30867706a30 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -78,8 +78,8 @@ RENAME_NODE_SCHEMA = vol.Schema({ SET_CONFIG_PARAMETER_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int), - vol.Required(const.ATTR_CONFIG_VALUE): vol.Coerce(int), - vol.Optional(const.ATTR_CONFIG_SIZE): vol.Coerce(int) + vol.Required(const.ATTR_CONFIG_VALUE): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(const.ATTR_CONFIG_SIZE, default=2): vol.Coerce(int) }) PRINT_CONFIG_PARAMETER_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), @@ -410,28 +410,28 @@ def setup(hass, config): node = network.nodes[node_id] param = service.data.get(const.ATTR_CONFIG_PARAMETER) selection = service.data.get(const.ATTR_CONFIG_VALUE) - size = service.data.get(const.ATTR_CONFIG_SIZE, 2) - i = 0 + size = service.data.get(const.ATTR_CONFIG_SIZE) for value in ( node.get_values(class_id=const.COMMAND_CLASS_CONFIGURATION) .values()): - if value.index == param and value.type == const.TYPE_LIST: - _LOGGER.debug("Values for parameter %s: %s", param, - value.data_items) - i = len(value.data_items) - 1 - if i == 0: - node.set_config_param(param, selection, size) - else: - if selection > i: - _LOGGER.error("Config parameter selection does not exist! " - "Please check zwcfg_[home_id].xml in " - "your homeassistant config directory. " - "Available selections are 0 to %s", i) + if value.index != param: + continue + if value.type in [const.TYPE_LIST, const.TYPE_BOOL]: + value.data = selection + _LOGGER.info("Setting config list parameter %s on Node %s " + "with selection %s", param, node_id, + selection) return - node.set_config_param(param, selection, size) - _LOGGER.info("Setting config parameter %s on Node %s " - "with selection %s and size=%s", param, node_id, - selection, size) + else: + value.data = int(selection) + _LOGGER.info("Setting config parameter %s on Node %s " + "with selection %s", param, node_id, + selection) + return + node.set_config_param(param, selection, size) + _LOGGER.info("Setting unknown config parameter %s on Node %s " + "with selection %s", param, node_id, + selection) def print_config_parameter(service): """Print a config parameter from a node.""" diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 166bd4e6f81..feacf8229aa 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -47,9 +47,9 @@ set_config_parameter: parameter: description: Parameter number to set (integer). value: - description: Value to set on parameter. (integer). + description: Value to set for parameter. (String value for list and bool parameters, integer for others). size: - description: (Optional) The size of the value. Defaults to 2. + description: (Optional) Set the size of the parameter value. Only needed if no parameters are available. print_config_parameter: description: Prints a Z-Wave node config parameter value to log. diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 57fd31be28f..17fac86c748 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -897,6 +897,7 @@ class TestZWaveServices(unittest.TestCase): value = MockValue( index=12, command_class=const.COMMAND_CLASS_CONFIGURATION, + type=const.TYPE_BYTE, ) value_list = MockValue( index=13, @@ -911,38 +912,32 @@ class TestZWaveServices(unittest.TestCase): self.hass.services.call('zwave', 'set_config_parameter', { const.ATTR_NODE_ID: 14, const.ATTR_CONFIG_PARAMETER: 13, - const.ATTR_CONFIG_VALUE: 1, + const.ATTR_CONFIG_VALUE: 'item3', }) self.hass.block_till_done() - assert node.set_config_param.called - assert len(node.set_config_param.mock_calls) == 1 - assert node.set_config_param.mock_calls[0][1][0] == 13 - assert node.set_config_param.mock_calls[0][1][1] == 1 - assert node.set_config_param.mock_calls[0][1][2] == 2 - node.set_config_param.reset_mock() - - self.hass.services.call('zwave', 'set_config_parameter', { - const.ATTR_NODE_ID: 14, - const.ATTR_CONFIG_PARAMETER: 13, - const.ATTR_CONFIG_VALUE: 7, - }) - self.hass.block_till_done() - - assert not node.set_config_param.called - node.set_config_param.reset_mock() + assert value_list.data == 'item3' self.hass.services.call('zwave', 'set_config_parameter', { const.ATTR_NODE_ID: 14, const.ATTR_CONFIG_PARAMETER: 12, + const.ATTR_CONFIG_VALUE: 7, + }) + self.hass.block_till_done() + + assert value.data == 7 + + self.hass.services.call('zwave', 'set_config_parameter', { + const.ATTR_NODE_ID: 14, + const.ATTR_CONFIG_PARAMETER: 19, const.ATTR_CONFIG_VALUE: 0x01020304, - const.ATTR_CONFIG_SIZE: 4, + const.ATTR_CONFIG_SIZE: 4 }) self.hass.block_till_done() assert node.set_config_param.called assert len(node.set_config_param.mock_calls) == 1 - assert node.set_config_param.mock_calls[0][1][0] == 12 + assert node.set_config_param.mock_calls[0][1][0] == 19 assert node.set_config_param.mock_calls[0][1][1] == 0x01020304 assert node.set_config_param.mock_calls[0][1][2] == 4 node.set_config_param.reset_mock() From c4da921cb53b3d47e6256d671f8c80aecaead834 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 18 May 2017 23:49:15 -0700 Subject: [PATCH 131/135] Add network_key as a config option (#7637) * Add network_key as a config option * Update __init__.py --- homeassistant/components/zwave/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 30867706a30..79067c0d2ef 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -54,6 +54,7 @@ CONF_REFRESH_DELAY = 'delay' CONF_DEVICE_CONFIG = 'device_config' CONF_DEVICE_CONFIG_GLOB = 'device_config_glob' CONF_DEVICE_CONFIG_DOMAIN = 'device_config_domain' +CONF_NETWORK_KEY = 'network_key' ATTR_POWER = 'power_consumption' @@ -125,6 +126,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_AUTOHEAL, default=DEFAULT_CONF_AUTOHEAL): cv.boolean, vol.Optional(CONF_CONFIG_PATH): cv.string, + vol.Optional(CONF_NETWORK_KEY): cv.string, vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema({cv.entity_id: DEVICE_CONFIG_SCHEMA_ENTRY}), vol.Optional(CONF_DEVICE_CONFIG_GLOB, default={}): @@ -245,6 +247,10 @@ def setup(hass, config): config_path=config[DOMAIN].get(CONF_CONFIG_PATH)) options.set_console_output(use_debug) + + if CONF_NETWORK_KEY in config[DOMAIN]: + options.addOption("NetworkKey", config[DOMAIN][CONF_NETWORK_KEY]) + options.lock() network = hass.data[ZWAVE_NETWORK] = ZWaveNetwork(options, autostart=False) From de85d38aa56ce3d4988f3bb0a65d2c581a8b08db Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 20 May 2017 08:07:32 -0700 Subject: [PATCH 132/135] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../www_static/home-assistant-polymer | 2 +- .../panels/ha-panel-automation.html | 2 +- .../panels/ha-panel-automation.html.gz | Bin 43960 -> 44908 bytes 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index d232f027f84..f4af26cc376 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -6,7 +6,7 @@ FINGERPRINTS = { "frontend.html": "fbb9d6bdd3d661db26cad9475a5e22f1", "mdi.html": "f407a5a57addbe93817ee1b244d33fbe", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-automation.html": "f9a6727e2354224577298fc0f2dadc2e", + "panels/ha-panel-automation.html": "21cba0a4fee9d2b45dda47f7a1dd82d8", "panels/ha-panel-config.html": "59d9eb28758b497a4d9b2428f978b9b1", "panels/ha-panel-dev-event.html": "2db9c218065ef0f61d8d08db8093cad2", "panels/ha-panel-dev-info.html": "61610e015a411cfc84edd2c4d489e71d", diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index ad3b3ce3dce..6858555c86f 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit ad3b3ce3dce3811cdc06e87585914c60c91e02af +Subproject commit 6858555c86f18eb0ab176008e9aa2c3842fec7ce diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html index 6453b12b24d..453d631c1da 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html @@ -1,2 +1,2 @@ \ No newline at end of file +return performance.now()};else var t=function(){return Date.now()};var e=function(t,e,i){this.target=t,this.currentTime=e,this.timelineTime=i,this.type="cancel",this.bubbles=!1,this.cancelable=!1,this.currentTarget=t,this.defaultPrevented=!1,this.eventPhase=Event.AT_TARGET,this.timeStamp=Date.now()},i=window.Element.prototype.animate;window.Element.prototype.animate=function(n,r){var o=i.call(this,n,r);o._cancelHandlers=[],o.oncancel=null;var a=o.cancel;o.cancel=function(){a.call(this);var i=new e(this,null,t()),n=this._cancelHandlers.concat(this.oncancel?[this.oncancel]:[]);setTimeout(function(){n.forEach(function(t){t.call(i.target,i)})},0)};var s=o.addEventListener;o.addEventListener=function(t,e){"function"==typeof e&&"cancel"==t?this._cancelHandlers.push(e):s.call(this,t,e)};var u=o.removeEventListener;return o.removeEventListener=function(t,e){if("cancel"==t){var i=this._cancelHandlers.indexOf(e);i>=0&&this._cancelHandlers.splice(i,1)}else u.call(this,t,e)},o}}}(),function(t){var e=document.documentElement,i=null,n=!1;try{var r=getComputedStyle(e).getPropertyValue("opacity"),o="0"==r?"1":"0";i=e.animate({opacity:[o,o]},{duration:1}),i.currentTime=0,n=getComputedStyle(e).getPropertyValue("opacity")==o}catch(t){}finally{i&&i.cancel()}if(!n){var a=window.Element.prototype.animate;window.Element.prototype.animate=function(e,i){return window.Symbol&&Symbol.iterator&&Array.prototype.from&&e[Symbol.iterator]&&(e=Array.from(e)),Array.isArray(e)||null===e||(e=t.convertToArrayForm(e)),a.call(this,e,i)}}}(c),function(t,e,i){function n(t){var i=e.timeline;i.currentTime=t,i._discardAnimations(),0==i._animations.length?o=!1:requestAnimationFrame(n)}var r=window.requestAnimationFrame;window.requestAnimationFrame=function(t){return r(function(i){e.timeline._updateAnimationsPromises(),t(i),e.timeline._updateAnimationsPromises()})},e.AnimationTimeline=function(){this._animations=[],this.currentTime=void 0},e.AnimationTimeline.prototype={getAnimations:function(){return this._discardAnimations(),this._animations.slice()},_updateAnimationsPromises:function(){e.animationsWithPromises=e.animationsWithPromises.filter(function(t){return t._updatePromises()})},_discardAnimations:function(){this._updateAnimationsPromises(),this._animations=this._animations.filter(function(t){return"finished"!=t.playState&&"idle"!=t.playState})},_play:function(t){var i=new e.Animation(t,this);return this._animations.push(i),e.restartWebAnimationsNextTick(),i._updatePromises(),i._animation.play(),i._updatePromises(),i},play:function(t){return t&&t.remove(),this._play(t)}};var o=!1;e.restartWebAnimationsNextTick=function(){o||(o=!0,requestAnimationFrame(n))};var a=new e.AnimationTimeline;e.timeline=a;try{Object.defineProperty(window.document,"timeline",{configurable:!0,get:function(){return a}})}catch(t){}try{window.document.timeline=a}catch(t){}}(0,e),function(t,e,i){e.animationsWithPromises=[],e.Animation=function(e,i){if(this.id="",e&&e._id&&(this.id=e._id),this.effect=e,e&&(e._animation=this),!i)throw new Error("Animation with null timeline is not supported");this._timeline=i,this._sequenceNumber=t.sequenceNumber++,this._holdTime=0,this._paused=!1,this._isGroup=!1,this._animation=null,this._childAnimations=[],this._callback=null,this._oldPlayState="idle",this._rebuildUnderlyingAnimation(),this._animation.cancel(),this._updatePromises()},e.Animation.prototype={_updatePromises:function(){var t=this._oldPlayState,e=this.playState;return this._readyPromise&&e!==t&&("idle"==e?(this._rejectReadyPromise(),this._readyPromise=void 0):"pending"==t?this._resolveReadyPromise():"pending"==e&&(this._readyPromise=void 0)),this._finishedPromise&&e!==t&&("idle"==e?(this._rejectFinishedPromise(),this._finishedPromise=void 0):"finished"==e?this._resolveFinishedPromise():"finished"==t&&(this._finishedPromise=void 0)),this._oldPlayState=this.playState,this._readyPromise||this._finishedPromise},_rebuildUnderlyingAnimation:function(){this._updatePromises();var t,i,n,r,o=!!this._animation;o&&(t=this.playbackRate,i=this._paused,n=this.startTime,r=this.currentTime,this._animation.cancel(),this._animation._wrapper=null,this._animation=null),(!this.effect||this.effect instanceof window.KeyframeEffect)&&(this._animation=e.newUnderlyingAnimationForKeyframeEffect(this.effect),e.bindAnimationForKeyframeEffect(this)),(this.effect instanceof window.SequenceEffect||this.effect instanceof window.GroupEffect)&&(this._animation=e.newUnderlyingAnimationForGroup(this.effect),e.bindAnimationForGroup(this)),this.effect&&this.effect._onsample&&e.bindAnimationForCustomEffect(this),o&&(1!=t&&(this.playbackRate=t),null!==n?this.startTime=n:null!==r?this.currentTime=r:null!==this._holdTime&&(this.currentTime=this._holdTime),i&&this.pause()),this._updatePromises()},_updateChildren:function(){if(this.effect&&"idle"!=this.playState){var t=this.effect._timing.delay;this._childAnimations.forEach(function(i){this._arrangeChildren(i,t),this.effect instanceof window.SequenceEffect&&(t+=e.groupChildDuration(i.effect))}.bind(this))}},_setExternalAnimation:function(t){if(this.effect&&this._isGroup)for(var e=0;e \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz index 20c4710b2c45b2ede9866a3a0cf05e77ca80dea5..f3137a76bca5cdfdd68d1572b03e766f701a20cf 100644 GIT binary patch delta 17064 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 delta 16106 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 From 45b4ef46ccd0a59ad4ee02a8478f52538c1c749e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 18 May 2017 09:57:38 +0200 Subject: [PATCH 133/135] Align with OpenALPR platform for naming conf variables (#7650) --- homeassistant/components/image_processing/seven_segments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/image_processing/seven_segments.py b/homeassistant/components/image_processing/seven_segments.py index 07b9b9d5d80..9b9c327f822 100644 --- a/homeassistant/components/image_processing/seven_segments.py +++ b/homeassistant/components/image_processing/seven_segments.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) CONF_DIGITS = 'digits' CONF_HEIGHT = 'height' -CONF_SSOCR_BIN = 'ssocr' +CONF_SSOCR_BIN = 'ssocr_bin' CONF_THRESHOLD = 'threshold' CONF_WIDTH = 'width' CONF_X_POS = 'x_position' From 23c5fc0aad84b3aaf32074585e949e8f09d262b4 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Fri, 19 May 2017 13:40:26 +0200 Subject: [PATCH 134/135] Bugfix #7586 (#7661) --- homeassistant/components/lock/zwave.py | 5 +++-- tests/components/lock/test_zwave.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index a46406e8361..7654d354a31 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -141,9 +141,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): if value.index != code_slot: continue - if len(str(usercode)) > 4: + if len(str(usercode)) < 4: _LOGGER.error("Invalid code provided: (%s) " - "usercode must %s or less digits", + "usercode must be atleast 4 and at most" + " %s digits", usercode, len(value.data)) break value.data = str(usercode) diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index 9fb634f49e2..b0e9456b8a8 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -173,8 +173,8 @@ def test_lock_set_usercode_service(hass, mock_openzwave): """Test the zwave lock set_usercode service.""" mock_network = hass.data[zwave.zwave.ZWAVE_NETWORK] = MagicMock() node = MockNode(node_id=12) - value0 = MockValue(data=None, node=node, index=0) - value1 = MockValue(data=None, node=node, index=1) + value0 = MockValue(data=' ', node=node, index=0) + value1 = MockValue(data=' ', node=node, index=1) yield from zwave.async_setup_platform( hass, {}, MagicMock()) @@ -202,7 +202,7 @@ def test_lock_set_usercode_service(hass, mock_openzwave): yield from hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_SET_USERCODE, { const.ATTR_NODE_ID: node.node_id, - zwave.ATTR_USERCODE: '12345', + zwave.ATTR_USERCODE: '123', zwave.ATTR_CODE_SLOT: 1, }) yield from hass.async_block_till_done() From 943958b140718bdc2ecf3762bde396333558dfad Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Thu, 18 May 2017 04:06:24 -0400 Subject: [PATCH 135/135] Added support to Amcrest camera to feed using RTSP via ffmpeg (#7646) * Implemented ffmpeg option on Amcrest camera and upgraded to version 1.2.0 * Added ffmpeg arguments and binary options to Amcrest camera * Added ffmpeg as dependencies * Makes lint happy and fixed requirements_all.txt * Inherent the ffmpeg.binary configuration from ffmpeg component * Update amcrest.py --- homeassistant/components/camera/amcrest.py | 42 ++++++++++++++++------ homeassistant/components/sensor/amcrest.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index 72d3120c77a..8f8b7e5f9f5 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -12,18 +12,22 @@ import voluptuous as vol import homeassistant.loader as loader from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) +from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_aiohttp_proxy_web) + async_get_clientsession, async_aiohttp_proxy_web, + async_aiohttp_proxy_stream) -REQUIREMENTS = ['amcrest==1.1.9'] +REQUIREMENTS = ['amcrest==1.2.0'] +DEPENDENCIES = ['ffmpeg'] _LOGGER = logging.getLogger(__name__) CONF_RESOLUTION = 'resolution' CONF_STREAM_SOURCE = 'stream_source' +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' DEFAULT_NAME = 'Amcrest Camera' DEFAULT_PORT = 80 @@ -40,7 +44,8 @@ RESOLUTION_LIST = { STREAM_SOURCE_LIST = { 'mjpeg': 0, - 'snapshot': 1 + 'snapshot': 1, + 'rtsp': 2, } CONTENT_TYPE_HEADER = 'Content-Type' @@ -56,6 +61,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_STREAM_SOURCE, default=DEFAULT_STREAM_SOURCE): vol.All(vol.In(STREAM_SOURCE_LIST)), + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, }) @@ -92,8 +98,9 @@ class AmcrestCam(Camera): super(AmcrestCam, self).__init__() self._camera = camera self._base_url = self._camera.get_base_url() - self._hass = hass self._name = device_info.get(CONF_NAME) + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) self._resolution = RESOLUTION_LIST[device_info.get(CONF_RESOLUTION)] self._stream_source = STREAM_SOURCE_LIST[ device_info.get(CONF_STREAM_SOURCE) @@ -117,15 +124,28 @@ class AmcrestCam(Camera): yield from super().handle_async_mjpeg_stream(request) return - # Otherwise, stream an MJPEG image stream directly from the camera - websession = async_get_clientsession(self.hass) - streaming_url = '{0}mjpg/video.cgi?channel=0&subtype={1}'.format( - self._base_url, self._resolution) + elif self._stream_source == STREAM_SOURCE_LIST['mjpeg']: + # stream an MJPEG image stream directly from the camera + websession = async_get_clientsession(self.hass) + streaming_url = self._camera.mjpeg_url(typeno=self._resolution) + stream_coro = websession.get( + streaming_url, auth=self._token, timeout=TIMEOUT) - stream_coro = websession.get( - streaming_url, auth=self._token, timeout=TIMEOUT) + yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) - yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) + else: + # streaming via fmpeg + from haffmpeg import CameraMjpeg + + streaming_url = self._camera.rtsp_url(typeno=self._resolution) + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + yield from stream.open_camera( + streaming_url, extra_cmd=self._ffmpeg_arguments) + + yield from async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + yield from stream.close() @property def name(self): diff --git a/homeassistant/components/sensor/amcrest.py b/homeassistant/components/sensor/amcrest.py index 40556fbe5ad..23f7fc4dfbe 100644 --- a/homeassistant/components/sensor/amcrest.py +++ b/homeassistant/components/sensor/amcrest.py @@ -19,7 +19,7 @@ import homeassistant.loader as loader from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['amcrest==1.1.9'] +REQUIREMENTS = ['amcrest==1.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a859a526305..c8f8c5aafd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -56,7 +56,7 @@ alarmdecoder==0.12.1.0 # homeassistant.components.camera.amcrest # homeassistant.components.sensor.amcrest -amcrest==1.1.9 +amcrest==1.2.0 # homeassistant.components.media_player.anthemav anthemav==1.1.8

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%0Aa2W)) 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 delta 94948 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 delta 2503 zcmV;&2{`u96VMX}ABzYGI++@g2PJ<9f)A^j%)M!*O`F%`G5L_@X7E@@Oej(%Av><^ z|K0^j$&w`{x6=={K#&p1mEDgb-lnWpmH}0_C8qh)$teWro8j+PA{$CYV-~a&fsTTTdLX=x{!a|K0LdG z$~@&|DY*RRf^WPVT{Q4!-7dDXH@sVSVS;|(D{eSG?aB%4oNxI2;XF?H=6VGeaOWRc z5`4WfJEk4z?krMJf&tv`W`!TlX0zAgD@fC$*T3?D5~{gboShAy%DO^Ntfi@&?Siwj zJ#oGUBNwhS;j3cOnRgv5%(8!E6FJ!6_%};1i&|YcZ(hH9@AwPg3Yzx9qWkxL?A|y} z>&hUOQ^0loi$NzPWLYm?~-Q0!q8}J62k#lbDJYUr6`r;q&UcU+~ zbE-L7(L3#uH=|Vp(lQ4%=wd$m^vRhxzLP+lr4iE%C?hHlLmH-uNK=2AkW4_rozFgg zsGH6#HCgcr%66jQatzkz<4B2IMkEUp20GLUQJD%eC9*K5lB=jg0rdzbQbr)o1rdTX zrV~mzgev8El%%OTt*5ATJx)kMLI?@tF(FdREC!__8LK$UAth>~CSgfN(-eRbDO8$i zP!jhfC`omyxjwC?h30>p^S7KCZV=l*r@d8i|T{z%g}MNX?Q zfl%m_(k$x)Z>&b6+euT4+SHS`-Ou>9x>SKN+LD7txs;ipae}52f-tid_(kjxDWeE!j z;C2PlIR1aBVQ*o+E_pLZqeOaZ!w$X#iLEesRQFYN%G9eu~Y zU{%d65lNn7-pLRmG>E6jl@ND`&I$=3DLW}Az<;Su@0TjpZ%`Lyy8okIqlVWQ+39d zbUb#>-4;q6G%zo)LUkqV$-zv}${H)Z<9L2CuP{cj)p^4klxVO%X;~=m*2|d-!g) zbN0cudKU+F*j+V%)uQVz{|VQ782XjZdmQB z@QHKgSl6CIC2IvA-adc2AscS?+?hGY-c+9F%RLtq?Hg41ae?$KkvX6q_`J}r?e2eI z=;gfL)JNfTpA5#^8b00nuC14Fv=1Kk*0of=ZLwC*JA$e`hW;`3p|l>6*=8{Ck9a|g zO`mZQDerXCK72ECZ0D|v5;Y(D&;;10HP=TN^$4>uwAj^u3O{>Y|9?`~*Se@Ss`@dS z_8=w}yj^q{^r8P{C-xm5PZ;g{dP9G$wnO73YTNHg4-Bx-nVB`vukeRpsNZ|e?-EB@ z_uVn_W42FVm+#wz9DYPL=zron#{2T~o##zEd6-MO!^{gystPg1xW(8hv6}8$2Ei3C z%!kSprQ7YG4KQa_?l$?IK205-;ZDZi}DerTYNQ;F6bXXl;M;*aNWY zq5$2DFxUv{;Oqf>3;$kQ-&g((f~Ii41uxqRr$x)hc)FMum2V<(CaUAw}Y;;wa#i&nB_6Rd%sjcF@JZoe16i^VX z)^<0hBk~=3^T>gA#{=l-_FI3k`iMU4>1!AGCkSAVtCvWueFpU0+#HQhx@7u)Fy+LV zzBPODEM|{uQitUfC_{|>yN|uG~1n`#^%b!5iF=Zbow8Ht@v-eJyjGLYnL4Oh+ zK*ZQSnM>XKHMoTz^#k72%pQ87C;rr6DXL}%T>|n2gBu5Zx9s_+1bBbbM|j?MDs0f^ zmIwP=!CG~8c2JJoPtNbI`{a?^cU8B1RovQKRo)H<%`aDV^9>s8t8SOPDzuelPxu(c z-k<-34cXq#yOv~|`|QbmKl|-wvy|w_aBC{} z@zZ);s_I{l&B)>n_d2?S^h@iEY%1+7xETHN`td&Am`R2!o$YkT{L|E5t=q+s3^DER zhtPdk$cCGz*2b8>n;4Gtz>E6o)y_4Si`BX?rR^vVVLrTT>+XNb9ArBbm!O>CO3mJ) z4YzLg$6lq~-=29t3hdn*H%tijI_q$&r*8zEZjChr?8f)*?_^nHazB{vQ9Cke-7~;> z^qpr{?d04ceRa(J6%*xv%@u}zedQdx_Ut+~pHGo07004oL>p%bi From eb473600f60735897020a7fada732b4738b9ef3d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 May 2017 23:03:27 -0700 Subject: [PATCH 128/135] Version bump to 0.45 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5b2367db718..65a8eb070c6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 45 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From 6662b7f52d5705f401f62efbfeba17d4c7a7f259 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 18 May 2017 17:40:49 -0700 Subject: [PATCH 129/135] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-zwave.html | 648 +++++++++++++++++- .../www_static/panels/ha-panel-zwave.html.gz | Bin 6135 -> 12987 bytes 4 files changed, 649 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index e0fd270b81b..d232f027f84 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -18,6 +18,6 @@ FINGERPRINTS = { "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab", "panels/ha-panel-logbook.html": "6dd6a16f52117318b202e60f98400163", "panels/ha-panel-map.html": "31c592c239636f91e07c7ac232a5ebc4", - "panels/ha-panel-zwave.html": "84fb45638d2a69bac343246a687f647c", + "panels/ha-panel-zwave.html": "19336d2c50c91dd6a122acc0606ff10d", "websocket_test.html": "575de64b431fe11c3785bf96d7813450" } diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index ffe0b22d772..ad3b3ce3dce 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit ffe0b22d772c619efabc43ae56d94e1564f9f6e6 +Subproject commit ad3b3ce3dce3811cdc06e87585914c60c91e02af diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html index 6af056b0db4..70e5cc64177 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html @@ -1 +1,647 @@ -