From fc0c8540d3f88275b894a56e3d4b83dd3cfb675c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 May 2017 23:03:47 -0700 Subject: [PATCH 001/105] Version bump to 0.46.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5b2367db718..f198b20a3c0 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 = 45 +MINOR_VERSION = 46 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 0eb6540fe7c8d58ebf6cde5909841618038af54d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 18 May 2017 09:57:38 +0200 Subject: [PATCH 002/105] 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 0fd415d7fb1701f70a38ff169b97cfcac9c2833a Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Thu, 18 May 2017 04:06:24 -0400 Subject: [PATCH 003/105] 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 From 238921b681659285ced3b9df77c5939f43b484a5 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Thu, 18 May 2017 16:37:39 -0500 Subject: [PATCH 004/105] bump fedex version (#7653) --- homeassistant/components/sensor/fedex.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index d1626ce2974..0e7d9dcad76 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle from homeassistant.util.dt import now, parse_date import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fedexdeliverymanager==1.0.2'] +REQUIREMENTS = ['fedexdeliverymanager==1.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c8f8c5aafd9..bca69420933 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -188,7 +188,7 @@ evohomeclient==0.2.5 fastdotcom==0.0.1 # homeassistant.components.sensor.fedex -fedexdeliverymanager==1.0.2 +fedexdeliverymanager==1.0.3 # homeassistant.components.feedreader feedparser==5.2.1 From f65cc68705a6f380582985c18653530fb8ed31bf Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Thu, 18 May 2017 16:38:50 -0500 Subject: [PATCH 005/105] bump ups version (#7654) --- homeassistant/components/sensor/ups.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/ups.py b/homeassistant/components/sensor/ups.py index 905cdab566e..cfb4dd7c9ce 100644 --- a/homeassistant/components/sensor/ups.py +++ b/homeassistant/components/sensor/ups.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle from homeassistant.util.dt import now, parse_date import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['upsmychoice==1.0.2'] +REQUIREMENTS = ['upsmychoice==1.0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index bca69420933..69a5eba2894 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -829,7 +829,7 @@ twilio==5.7.0 uber_rides==0.4.1 # homeassistant.components.sensor.ups -upsmychoice==1.0.2 +upsmychoice==1.0.4 # homeassistant.components.camera.uvc uvcclient==0.10.0 From e479324db9bedb2e941e1d32d1af8c13df331e31 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Thu, 18 May 2017 19:30:43 -0500 Subject: [PATCH 006/105] update usps (#7655) * update usps * fix doc --- homeassistant/components/sensor/usps.py | 74 ++++++++++++++++++------- requirements_all.txt | 2 +- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/sensor/usps.py b/homeassistant/components/sensor/usps.py index ec1b2f5575e..4157364eb4b 100644 --- a/homeassistant/components/sensor/usps.py +++ b/homeassistant/components/sensor/usps.py @@ -15,31 +15,28 @@ from homeassistant.const import (CONF_NAME, CONF_USERNAME, CONF_PASSWORD, ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -from homeassistant.util import Throttle from homeassistant.util.dt import now, parse_datetime import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['myusps==1.0.5'] +REQUIREMENTS = ['myusps==1.1.1'] _LOGGER = logging.getLogger(__name__) +DOMAIN = 'usps' +SCAN_INTERVAL = timedelta(minutes=30) COOKIE = 'usps_cookies.pickle' -CONF_UPDATE_INTERVAL = 'update_interval' -ICON = 'mdi:package-variant-closed' STATUS_DELIVERED = 'delivered' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=1800)): ( - vol.All(cv.time_period, cv.positive_timedelta)), + vol.Optional(CONF_NAME): cv.string }) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the USPS platform.""" + """Setup the USPS platform.""" import myusps try: cookie = hass.config.path(COOKIE) @@ -47,38 +44,34 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_USERNAME), config.get(CONF_PASSWORD), cookie_path=cookie) except myusps.USPSError: - _LOGGER.exception("Could not connect to My USPS") + _LOGGER.exception('Could not connect to My USPS') return False - add_devices([USPSSensor(session, config.get(CONF_NAME), - config.get(CONF_UPDATE_INTERVAL))]) + add_devices([USPSPackageSensor(session, config.get(CONF_NAME)), + USPSMailSensor(session, config.get(CONF_NAME))], True) -class USPSSensor(Entity): - """USPS Sensor.""" +class USPSPackageSensor(Entity): + """USPS Package Sensor.""" - def __init__(self, session, name, interval): + def __init__(self, session, name): """Initialize the sensor.""" - import myusps self._session = session self._name = name - self._profile = myusps.get_profile(session) self._attributes = None self._state = None - self.update = Throttle(interval)(self._update) - self.update() @property def name(self): """Return the name of the sensor.""" - return self._name or self._profile.get('address') + return '{} packages'.format(self._name or DOMAIN) @property def state(self): """Return the state of the sensor.""" return self._state - def _update(self): + def update(self): """Update device state.""" import myusps status_counts = defaultdict(int) @@ -102,4 +95,43 @@ class USPSSensor(Entity): @property def icon(self): """Icon to use in the frontend.""" - return ICON + return 'mdi:package-variant-closed' + + +class USPSMailSensor(Entity): + """USPS Mail Sensor.""" + + def __init__(self, session, name): + """Initialize the sensor.""" + self._session = session + self._name = name + self._attributes = None + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return '{} mail'.format(self._name or DOMAIN) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Update device state.""" + import myusps + self._state = len(myusps.get_mail(self._session)) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + import myusps + return { + ATTR_ATTRIBUTION: myusps.ATTRIBUTION + } + + @property + def icon(self): + """Icon to use in the frontend.""" + return 'mdi:mailbox' diff --git a/requirements_all.txt b/requirements_all.txt index 69a5eba2894..aab5de87f90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -382,7 +382,7 @@ miflora==0.1.16 mutagen==1.37.0 # homeassistant.components.sensor.usps -myusps==1.0.5 +myusps==1.1.1 # homeassistant.components.discovery netdisco==1.0.0 From 88ffe3994560f3204351a7dfa728b7d2c2eb418d Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Fri, 19 May 2017 02:39:31 +0200 Subject: [PATCH 007/105] 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 e91fe9458509f1d63a93c33d3c1058023cd92e8d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 18 May 2017 17:40:49 -0700 Subject: [PATCH 008/105] 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 @@ - \ 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 7daa92249a94678ac3fdca36cdaf6e4c526e6c59 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 18 May 2017 23:49:15 -0700 Subject: [PATCH 009/105] 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 5aa72562a7b87df905321a2b59feda2f7007e6f6 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Fri, 19 May 2017 13:40:26 +0200 Subject: [PATCH 010/105] 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 d369d70ca50eb6b41069413e5f2d17f1fbf1b315 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 19 May 2017 07:37:39 -0700 Subject: [PATCH 011/105] Fix tests (#7659) * Remove global hass * Http.auth test no longer spin up server * Remove server usage from http.ban test * Remove setupModule from test device_sun_light_trigger * Update common.py --- .../components/device_tracker/__init__.py | 24 +- tests/components/http/test_auth.py | 214 +++--- tests/components/http/test_ban.py | 150 ++--- tests/components/test_api.py | 616 +++++++++--------- .../test_device_sun_light_trigger.py | 53 +- tests/components/test_frontend.py | 6 +- 6 files changed, 499 insertions(+), 564 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 6582ba3f57e..8770aaafaa8 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -150,14 +150,14 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): scanner = yield from platform.async_get_scanner( hass, {DOMAIN: p_config}) elif hasattr(platform, 'get_scanner'): - scanner = yield from hass.loop.run_in_executor( - None, platform.get_scanner, hass, {DOMAIN: p_config}) + scanner = yield from hass.async_add_job( + platform.get_scanner, hass, {DOMAIN: p_config}) elif hasattr(platform, 'async_setup_scanner'): setup = yield from platform.async_setup_scanner( hass, p_config, tracker.async_see, disc_info) elif hasattr(platform, 'setup_scanner'): - setup = yield from hass.loop.run_in_executor( - None, platform.setup_scanner, hass, p_config, tracker.see, + setup = yield from hass.async_add_job( + platform.setup_scanner, hass, p_config, tracker.see, disc_info) else: raise HomeAssistantError("Invalid device_tracker platform.") @@ -209,8 +209,8 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)} yield from tracker.async_see(**args) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml') ) hass.services.async_register( @@ -322,8 +322,8 @@ class DeviceTracker(object): This method is a coroutine. """ with (yield from self._is_updating): - yield from self.hass.loop.run_in_executor( - None, update_config, self.hass.config.path(YAML_DEVICES), + yield from self.hass.async_add_job( + update_config, self.hass.config.path(YAML_DEVICES), dev_id, device) @asyncio.coroutine @@ -608,7 +608,7 @@ class DeviceScanner(object): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor(None, self.scan_devices) + return self.hass.async_add_job(self.scan_devices) def get_device_name(self, mac: str) -> str: """Get device name from mac.""" @@ -619,7 +619,7 @@ class DeviceScanner(object): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor(None, self.get_device_name, mac) + return self.hass.async_add_job(self.get_device_name, mac) def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): @@ -650,8 +650,8 @@ def async_load_config(path: str, hass: HomeAssistantType, try: result = [] try: - devices = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, path) + devices = yield from hass.async_add_job( + load_yaml_config_file, path) except HomeAssistantError as err: _LOGGER.error("Unable to load %s: %s", path, str(err)) return [] diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 729e6f22be6..5db42b01371 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,26 +1,19 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access -import logging +import asyncio from ipaddress import ip_address, ip_network from unittest.mock import patch -import requests +import pytest -from homeassistant import setup, const +from homeassistant import const +from homeassistant.setup import async_setup_component import homeassistant.components.http as http from homeassistant.components.http.const import ( KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR, HTTP_HEADER_X_FORWARDED_FOR) -from tests.common import get_test_instance_port, get_test_home_assistant - API_PASSWORD = 'test1234' -SERVER_PORT = get_test_instance_port() -HTTP_BASE = '127.0.0.1:{}'.format(SERVER_PORT) -HTTP_BASE_URL = 'http://{}'.format(HTTP_BASE) -HA_HEADERS = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, -} + # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases TRUSTED_NETWORKS = ['192.0.2.0/24', '2001:DB8:ABCD::/48', '100.64.0.1', 'FD01:DB8::1'] @@ -28,142 +21,131 @@ TRUSTED_ADDRESSES = ['100.64.0.1', '192.0.2.100', 'FD01:DB8::1', '2001:DB8:ABCD::1'] UNTRUSTED_ADDRESSES = ['198.51.100.1', '2001:DB8:FA1::1', '127.0.0.1', '::1'] -hass = None - -def _url(path=''): - """Helper method to generate URLs.""" - return HTTP_BASE_URL + path - - -# pylint: disable=invalid-name -def setUpModule(): - """Initialize a Home Assistant server.""" - global hass - - hass = get_test_home_assistant() - - setup.setup_component( - hass, http.DOMAIN, { - http.DOMAIN: { - http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SERVER_PORT, - } +@pytest.fixture +def mock_api_client(hass, test_client): + """Start the Hass HTTP component.""" + hass.loop.run_until_complete(async_setup_component(hass, 'api', { + 'http': { + http.CONF_API_PASSWORD: API_PASSWORD, } - ) + })) + return hass.loop.run_until_complete(test_client(hass.http.app)) - setup.setup_component(hass, 'api') +@pytest.fixture +def mock_trusted_networks(hass, mock_api_client): + """Mock trusted networks.""" hass.http.app[KEY_TRUSTED_NETWORKS] = [ ip_network(trusted_network) for trusted_network in TRUSTED_NETWORKS] - hass.start() + +@asyncio.coroutine +def test_access_denied_without_password(mock_api_client): + """Test access without password.""" + resp = yield from mock_api_client.get(const.URL_API) + assert resp.status == 401 -# pylint: disable=invalid-name -def tearDownModule(): - """Stop the Home Assistant server.""" - hass.stop() +@asyncio.coroutine +def test_access_denied_with_wrong_password_in_header(mock_api_client): + """Test access with wrong password.""" + resp = yield from mock_api_client.get(const.URL_API, headers={ + const.HTTP_HEADER_HA_AUTH: 'wrongpassword' + }) + assert resp.status == 401 -class TestHttp: - """Test HTTP component.""" +@asyncio.coroutine +def test_access_denied_with_x_forwarded_for(hass, mock_api_client, + mock_trusted_networks): + """Test access denied through the X-Forwarded-For http header.""" + hass.http.use_x_forwarded_for = True + for remote_addr in UNTRUSTED_ADDRESSES: + resp = yield from mock_api_client.get(const.URL_API, headers={ + HTTP_HEADER_X_FORWARDED_FOR: remote_addr}) - def test_access_denied_without_password(self): - """Test access without password.""" - req = requests.get(_url(const.URL_API)) + assert resp.status == 401, \ + "{} shouldn't be trusted".format(remote_addr) - assert req.status_code == 401 - def test_access_denied_with_wrong_password_in_header(self): - """Test access with wrong password.""" - req = requests.get( - _url(const.URL_API), - headers={const.HTTP_HEADER_HA_AUTH: 'wrongpassword'}) +@asyncio.coroutine +def test_access_denied_with_untrusted_ip(mock_api_client, + mock_trusted_networks): + """Test access with an untrusted ip address.""" + for remote_addr in UNTRUSTED_ADDRESSES: + with patch('homeassistant.components.http.' + 'util.get_real_ip', + return_value=ip_address(remote_addr)): + resp = yield from mock_api_client.get( + const.URL_API, params={'api_password': ''}) - assert req.status_code == 401 - - def test_access_denied_with_x_forwarded_for(self, caplog): - """Test access denied through the X-Forwarded-For http header.""" - hass.http.use_x_forwarded_for = True - for remote_addr in UNTRUSTED_ADDRESSES: - req = requests.get(_url(const.URL_API), headers={ - HTTP_HEADER_X_FORWARDED_FOR: remote_addr}) - - assert req.status_code == 401, \ + assert resp.status == 401, \ "{} shouldn't be trusted".format(remote_addr) - def test_access_denied_with_untrusted_ip(self, caplog): - """Test access with an untrusted ip address.""" - for remote_addr in UNTRUSTED_ADDRESSES: - with patch('homeassistant.components.http.' - 'util.get_real_ip', - return_value=ip_address(remote_addr)): - req = requests.get( - _url(const.URL_API), params={'api_password': ''}) - assert req.status_code == 401, \ - "{} shouldn't be trusted".format(remote_addr) +@asyncio.coroutine +def test_access_with_password_in_header(mock_api_client, caplog): + """Test access with password in URL.""" + # Hide logging from requests package that we use to test logging + req = yield from mock_api_client.get( + const.URL_API, headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD}) - def test_access_with_password_in_header(self, caplog): - """Test access with password in URL.""" - # Hide logging from requests package that we use to test logging - caplog.set_level( - logging.WARNING, logger='requests.packages.urllib3.connectionpool') + assert req.status == 200 - req = requests.get( - _url(const.URL_API), - headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD}) + logs = caplog.text - assert req.status_code == 200 + assert const.URL_API in logs + assert API_PASSWORD not in logs - logs = caplog.text - assert const.URL_API in logs - assert API_PASSWORD not in logs +@asyncio.coroutine +def test_access_denied_with_wrong_password_in_url(mock_api_client): + """Test access with wrong password.""" + resp = yield from mock_api_client.get( + const.URL_API, params={'api_password': 'wrongpassword'}) - def test_access_denied_with_wrong_password_in_url(self): - """Test access with wrong password.""" - req = requests.get( - _url(const.URL_API), params={'api_password': 'wrongpassword'}) + assert resp.status == 401 - assert req.status_code == 401 - def test_access_with_password_in_url(self, caplog): - """Test access with password in URL.""" - # Hide logging from requests package that we use to test logging - caplog.set_level( - logging.WARNING, logger='requests.packages.urllib3.connectionpool') +@asyncio.coroutine +def test_access_with_password_in_url(mock_api_client, caplog): + """Test access with password in URL.""" + req = yield from mock_api_client.get( + const.URL_API, params={'api_password': API_PASSWORD}) - req = requests.get( - _url(const.URL_API), params={'api_password': API_PASSWORD}) + assert req.status == 200 - assert req.status_code == 200 + logs = caplog.text - logs = caplog.text + assert const.URL_API in logs + assert API_PASSWORD not in logs - assert const.URL_API in logs - assert API_PASSWORD not in logs - def test_access_granted_with_x_forwarded_for(self, caplog): - """Test access denied through the X-Forwarded-For http header.""" - hass.http.app[KEY_USE_X_FORWARDED_FOR] = True - for remote_addr in TRUSTED_ADDRESSES: - req = requests.get(_url(const.URL_API), headers={ - HTTP_HEADER_X_FORWARDED_FOR: remote_addr}) +@asyncio.coroutine +def test_access_granted_with_x_forwarded_for(hass, mock_api_client, caplog, + mock_trusted_networks): + """Test access denied through the X-Forwarded-For http header.""" + hass.http.app[KEY_USE_X_FORWARDED_FOR] = True + for remote_addr in TRUSTED_ADDRESSES: + resp = yield from mock_api_client.get(const.URL_API, headers={ + HTTP_HEADER_X_FORWARDED_FOR: remote_addr}) - assert req.status_code == 200, \ - "{} should be trusted".format(remote_addr) + assert resp.status == 200, \ + "{} should be trusted".format(remote_addr) - def test_access_granted_with_trusted_ip(self, caplog): - """Test access with trusted addresses.""" - for remote_addr in TRUSTED_ADDRESSES: - with patch('homeassistant.components.http.' - 'auth.get_real_ip', - return_value=ip_address(remote_addr)): - req = requests.get( - _url(const.URL_API), params={'api_password': ''}) - assert req.status_code == 200, \ - '{} should be trusted'.format(remote_addr) +@asyncio.coroutine +def test_access_granted_with_trusted_ip(mock_api_client, caplog, + mock_trusted_networks): + """Test access with trusted addresses.""" + for remote_addr in TRUSTED_ADDRESSES: + with patch('homeassistant.components.http.' + 'auth.get_real_ip', + return_value=ip_address(remote_addr)): + resp = yield from mock_api_client.get( + const.URL_API, params={'api_password': ''}) + + assert resp.status == 200, \ + '{} should be trusted'.format(remote_addr) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 0d8f1a92c7f..c9147367c10 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -1,117 +1,91 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access +import asyncio from ipaddress import ip_address from unittest.mock import patch, mock_open -import requests +import pytest -from homeassistant import setup, const +from homeassistant import const +from homeassistant.setup import async_setup_component import homeassistant.components.http as http from homeassistant.components.http.const import ( KEY_BANS_ENABLED, KEY_LOGIN_THRESHOLD, KEY_BANNED_IPS) from homeassistant.components.http.ban import IpBan, IP_BANS_FILE -from tests.common import get_test_instance_port, get_test_home_assistant - API_PASSWORD = 'test1234' -SERVER_PORT = get_test_instance_port() -HTTP_BASE = '127.0.0.1:{}'.format(SERVER_PORT) -HTTP_BASE_URL = 'http://{}'.format(HTTP_BASE) -HA_HEADERS = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, -} BANNED_IPS = ['200.201.202.203', '100.64.0.2'] -hass = None - -def _url(path=''): - """Helper method to generate URLs.""" - return HTTP_BASE_URL + path - - -# pylint: disable=invalid-name -def setUpModule(): - """Initialize a Home Assistant server.""" - global hass - - hass = get_test_home_assistant() - - setup.setup_component( - hass, http.DOMAIN, { - http.DOMAIN: { - http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SERVER_PORT, - } +@pytest.fixture +def mock_api_client(hass, test_client): + """Start the Hass HTTP component.""" + hass.loop.run_until_complete(async_setup_component(hass, 'api', { + 'http': { + http.CONF_API_PASSWORD: API_PASSWORD, } - ) - - setup.setup_component(hass, 'api') - + })) hass.http.app[KEY_BANNED_IPS] = [IpBan(banned_ip) for banned_ip in BANNED_IPS] - hass.start() + return hass.loop.run_until_complete(test_client(hass.http.app)) -# pylint: disable=invalid-name -def tearDownModule(): - """Stop the Home Assistant server.""" - hass.stop() +@asyncio.coroutine +def test_access_from_banned_ip(hass, mock_api_client): + """Test accessing to server from banned IP. Both trusted and not.""" + hass.http.app[KEY_BANS_ENABLED] = True + for remote_addr in BANNED_IPS: + with patch('homeassistant.components.http.' + 'ban.get_real_ip', + return_value=ip_address(remote_addr)): + resp = yield from mock_api_client.get( + const.URL_API) + assert resp.status == 403 -class TestHttp: - """Test HTTP component.""" +@asyncio.coroutine +def test_access_from_banned_ip_when_ban_is_off(hass, mock_api_client): + """Test accessing to server from banned IP when feature is off.""" + hass.http.app[KEY_BANS_ENABLED] = False + for remote_addr in BANNED_IPS: + with patch('homeassistant.components.http.' + 'ban.get_real_ip', + return_value=ip_address(remote_addr)): + resp = yield from mock_api_client.get( + const.URL_API, + headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert resp.status == 200 - def test_access_from_banned_ip(self): - """Test accessing to server from banned IP. Both trusted and not.""" - hass.http.app[KEY_BANS_ENABLED] = True - for remote_addr in BANNED_IPS: - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address(remote_addr)): - req = requests.get( - _url(const.URL_API)) - assert req.status_code == 403 - def test_access_from_banned_ip_when_ban_is_off(self): - """Test accessing to server from banned IP when feature is off.""" - hass.http.app[KEY_BANS_ENABLED] = False - for remote_addr in BANNED_IPS: - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address(remote_addr)): - req = requests.get( - _url(const.URL_API), - headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD}) - assert req.status_code == 200 +@asyncio.coroutine +def test_ip_bans_file_creation(hass, mock_api_client): + """Testing if banned IP file created.""" + hass.http.app[KEY_BANS_ENABLED] = True + hass.http.app[KEY_LOGIN_THRESHOLD] = 1 - def test_ip_bans_file_creation(self): - """Testing if banned IP file created.""" - hass.http.app[KEY_BANS_ENABLED] = True - hass.http.app[KEY_LOGIN_THRESHOLD] = 1 + m = mock_open() - m = mock_open() + @asyncio.coroutine + def call_server(): + with patch('homeassistant.components.http.' + 'ban.get_real_ip', + return_value=ip_address("200.201.202.204")): + resp = yield from mock_api_client.get( + const.URL_API, + headers={const.HTTP_HEADER_HA_AUTH: 'Wrong password'}) + return resp - def call_server(): - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address("200.201.202.204")): - return requests.get( - _url(const.URL_API), - headers={const.HTTP_HEADER_HA_AUTH: 'Wrong password'}) + with patch('homeassistant.components.http.ban.open', m, create=True): + resp = yield from call_server() + assert resp.status == 401 + assert len(hass.http.app[KEY_BANNED_IPS]) == len(BANNED_IPS) + assert m.call_count == 0 - with patch('homeassistant.components.http.ban.open', m, create=True): - req = call_server() - assert req.status_code == 401 - assert len(hass.http.app[KEY_BANNED_IPS]) == len(BANNED_IPS) - assert m.call_count == 0 + resp = yield from call_server() + assert resp.status == 401 + assert len(hass.http.app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 + m.assert_called_once_with(hass.config.path(IP_BANS_FILE), 'a') - req = call_server() - assert req.status_code == 401 - assert len(hass.http.app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 - m.assert_called_once_with(hass.config.path(IP_BANS_FILE), 'a') - - req = call_server() - assert req.status_code == 403 - assert m.call_count == 1 + resp = yield from call_server() + assert resp.status == 403 + assert m.call_count == 1 diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 8d6041b49c1..f110a832752 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -1,394 +1,380 @@ """The tests for the Home Assistant API component.""" # pylint: disable=protected-access -from contextlib import closing +import asyncio import json -import unittest -import requests +import pytest -from homeassistant import setup, const +from homeassistant import const import homeassistant.core as ha -import homeassistant.components.http as http - -from tests.common import get_test_instance_port, get_test_home_assistant - -API_PASSWORD = "test1234" -SERVER_PORT = get_test_instance_port() -HTTP_BASE_URL = "http://127.0.0.1:{}".format(SERVER_PORT) -HA_HEADERS = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, -} - -hass = None +from homeassistant.setup import async_setup_component -def _url(path=""): - """Helper method to generate URLs.""" - return HTTP_BASE_URL + path +@pytest.fixture +def mock_api_client(hass, test_client): + """Start the Hass HTTP component.""" + hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_api_list_state_entities(hass, mock_api_client): + """Test if the debug interface allows us to list state entities.""" + hass.states.async_set('test.entity', 'hello') + resp = yield from mock_api_client.get(const.URL_API_STATES) + assert resp.status == 200 + json = yield from resp.json() + + remote_data = [ha.State.from_dict(item) for item in json] + assert remote_data == hass.states.async_all() + + +@asyncio.coroutine +def test_api_get_state(hass, mock_api_client): + """Test if the debug interface allows us to get a state.""" + hass.states.async_set('hello.world', 'nice', { + 'attr': 1, + }) + resp = yield from mock_api_client.get( + const.URL_API_STATES_ENTITY.format("hello.world")) + assert resp.status == 200 + json = yield from resp.json() + + data = ha.State.from_dict(json) + + state = hass.states.get("hello.world") + + assert data.state == state.state + assert data.last_changed == state.last_changed + assert data.attributes == state.attributes + + +@asyncio.coroutine +def test_api_get_non_existing_state(hass, mock_api_client): + """Test if the debug interface allows us to get a state.""" + resp = yield from mock_api_client.get( + const.URL_API_STATES_ENTITY.format("does_not_exist")) + assert resp.status == 404 + + +@asyncio.coroutine +def test_api_state_change(hass, mock_api_client): + """Test if we can change the state of an entity that exists.""" + hass.states.async_set("test.test", "not_to_be_set") + + yield from mock_api_client.post( + const.URL_API_STATES_ENTITY.format("test.test"), + json={"state": "debug_state_change2"}) + + assert hass.states.get("test.test").state == "debug_state_change2" # pylint: disable=invalid-name -def setUpModule(): - """Initialize a Home Assistant server.""" - global hass +@asyncio.coroutine +def test_api_state_change_of_non_existing_entity(hass, mock_api_client): + """Test if changing a state of a non existing entity is possible.""" + new_state = "debug_state_change" - hass = get_test_home_assistant() + resp = yield from mock_api_client.post( + const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"), + json={'state': new_state}) - hass.bus.listen('test_event', lambda _: _) - hass.states.set('test.test', 'a_state') + assert resp.status == 201 - setup.setup_component( - hass, http.DOMAIN, - {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SERVER_PORT}}) - - setup.setup_component(hass, 'api') - - hass.start() + assert hass.states.get("test_entity.that_does_not_exist").state == \ + new_state # pylint: disable=invalid-name -def tearDownModule(): - """Stop the Home Assistant server.""" - hass.stop() +@asyncio.coroutine +def test_api_state_change_with_bad_data(hass, mock_api_client): + """Test if API sends appropriate error if we omit state.""" + resp = yield from mock_api_client.post( + const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"), + json={}) + + assert resp.status == 400 -class TestAPI(unittest.TestCase): - """Test the API.""" +# pylint: disable=invalid-name +@asyncio.coroutine +def test_api_state_change_push(hass, mock_api_client): + """Test if we can push a change the state of an entity.""" + hass.states.async_set("test.test", "not_to_be_set") - def tearDown(self): - """Stop everything that was started.""" - hass.block_till_done() + events = [] - def test_api_list_state_entities(self): - """Test if the debug interface allows us to list state entities.""" - req = requests.get(_url(const.URL_API_STATES), - headers=HA_HEADERS) + @ha.callback + def event_listener(event): + """Track events.""" + events.append(event) - remote_data = [ha.State.from_dict(item) for item in req.json()] + hass.bus.async_listen(const.EVENT_STATE_CHANGED, event_listener) - self.assertEqual(hass.states.all(), remote_data) + yield from mock_api_client.post( + const.URL_API_STATES_ENTITY.format("test.test"), + json={"state": "not_to_be_set"}) + yield from hass.async_block_till_done() + assert len(events) == 0 - def test_api_get_state(self): - """Test if the debug interface allows us to get a state.""" - req = requests.get( - _url(const.URL_API_STATES_ENTITY.format("test.test")), - headers=HA_HEADERS) + yield from mock_api_client.post( + const.URL_API_STATES_ENTITY.format("test.test"), + json={"state": "not_to_be_set", "force_update": True}) + yield from hass.async_block_till_done() + assert len(events) == 1 - data = ha.State.from_dict(req.json()) - state = hass.states.get("test.test") +# pylint: disable=invalid-name +@asyncio.coroutine +def test_api_fire_event_with_no_data(hass, mock_api_client): + """Test if the API allows us to fire an event.""" + test_value = [] - self.assertEqual(state.state, data.state) - self.assertEqual(state.last_changed, data.last_changed) - self.assertEqual(state.attributes, data.attributes) + @ha.callback + def listener(event): + """Helper method that will verify our event got called.""" + test_value.append(1) - def test_api_get_non_existing_state(self): - """Test if the debug interface allows us to get a state.""" - req = requests.get( - _url(const.URL_API_STATES_ENTITY.format("does_not_exist")), - headers=HA_HEADERS) + hass.bus.async_listen_once("test.event_no_data", listener) - self.assertEqual(404, req.status_code) + yield from mock_api_client.post( + const.URL_API_EVENTS_EVENT.format("test.event_no_data")) + yield from hass.async_block_till_done() - def test_api_state_change(self): - """Test if we can change the state of an entity that exists.""" - hass.states.set("test.test", "not_to_be_set") + assert len(test_value) == 1 - requests.post(_url(const.URL_API_STATES_ENTITY.format("test.test")), - data=json.dumps({"state": "debug_state_change2"}), - headers=HA_HEADERS) - self.assertEqual("debug_state_change2", - hass.states.get("test.test").state) +# pylint: disable=invalid-name +@asyncio.coroutine +def test_api_fire_event_with_data(hass, mock_api_client): + """Test if the API allows us to fire an event.""" + test_value = [] - # pylint: disable=invalid-name - def test_api_state_change_of_non_existing_entity(self): - """Test if changing a state of a non existing entity is possible.""" - new_state = "debug_state_change" + @ha.callback + def listener(event): + """Helper method that will verify that our event got called. - req = requests.post( - _url(const.URL_API_STATES_ENTITY.format( - "test_entity.that_does_not_exist")), - data=json.dumps({'state': new_state}), - headers=HA_HEADERS) - - cur_state = (hass.states. - get("test_entity.that_does_not_exist").state) - - self.assertEqual(201, req.status_code) - self.assertEqual(cur_state, new_state) - - # pylint: disable=invalid-name - def test_api_state_change_with_bad_data(self): - """Test if API sends appropriate error if we omit state.""" - req = requests.post( - _url(const.URL_API_STATES_ENTITY.format( - "test_entity.that_does_not_exist")), - data=json.dumps({}), - headers=HA_HEADERS) - - self.assertEqual(400, req.status_code) - - # pylint: disable=invalid-name - def test_api_state_change_push(self): - """Test if we can push a change the state of an entity.""" - hass.states.set("test.test", "not_to_be_set") - - events = [] - hass.bus.listen(const.EVENT_STATE_CHANGED, - lambda ev: events.append(ev)) - - requests.post(_url(const.URL_API_STATES_ENTITY.format("test.test")), - data=json.dumps({"state": "not_to_be_set"}), - headers=HA_HEADERS) - hass.block_till_done() - self.assertEqual(0, len(events)) - - requests.post(_url(const.URL_API_STATES_ENTITY.format("test.test")), - data=json.dumps({"state": "not_to_be_set", - "force_update": True}), - headers=HA_HEADERS) - hass.block_till_done() - self.assertEqual(1, len(events)) - - # pylint: disable=invalid-name - def test_api_fire_event_with_no_data(self): - """Test if the API allows us to fire an event.""" - test_value = [] - - def listener(event): - """Helper method that will verify our event got called.""" + Also test if our data came through. + """ + if "test" in event.data: test_value.append(1) - hass.bus.listen_once("test.event_no_data", listener) + hass.bus.async_listen_once("test_event_with_data", listener) - requests.post( - _url(const.URL_API_EVENTS_EVENT.format("test.event_no_data")), - headers=HA_HEADERS) + yield from mock_api_client.post( + const.URL_API_EVENTS_EVENT.format("test_event_with_data"), + json={"test": 1}) - hass.block_till_done() + yield from hass.async_block_till_done() - self.assertEqual(1, len(test_value)) + assert len(test_value) == 1 - # pylint: disable=invalid-name - def test_api_fire_event_with_data(self): - """Test if the API allows us to fire an event.""" - test_value = [] - def listener(event): - """Helper method that will verify that our event got called. +# pylint: disable=invalid-name +@asyncio.coroutine +def test_api_fire_event_with_invalid_json(hass, mock_api_client): + """Test if the API allows us to fire an event.""" + test_value = [] - Also test if our data came through. - """ - if "test" in event.data: - test_value.append(1) + @ha.callback + def listener(event): + """Helper method that will verify our event got called.""" + test_value.append(1) - hass.bus.listen_once("test_event_with_data", listener) + hass.bus.async_listen_once("test_event_bad_data", listener) - requests.post( - _url(const.URL_API_EVENTS_EVENT.format("test_event_with_data")), - data=json.dumps({"test": 1}), - headers=HA_HEADERS) + resp = yield from mock_api_client.post( + const.URL_API_EVENTS_EVENT.format("test_event_bad_data"), + data=json.dumps('not an object')) - hass.block_till_done() + yield from hass.async_block_till_done() - self.assertEqual(1, len(test_value)) + assert resp.status == 400 + assert len(test_value) == 0 - # pylint: disable=invalid-name - def test_api_fire_event_with_invalid_json(self): - """Test if the API allows us to fire an event.""" - test_value = [] + # Try now with valid but unusable JSON + resp = yield from mock_api_client.post( + const.URL_API_EVENTS_EVENT.format("test_event_bad_data"), + data=json.dumps([1, 2, 3])) - def listener(event): - """Helper method that will verify our event got called.""" + yield from hass.async_block_till_done() + + assert resp.status == 400 + assert len(test_value) == 0 + + +@asyncio.coroutine +def test_api_get_config(hass, mock_api_client): + """Test the return of the configuration.""" + resp = yield from mock_api_client.get(const.URL_API_CONFIG) + result = yield from resp.json() + if 'components' in result: + result['components'] = set(result['components']) + + assert hass.config.as_dict() == result + + +@asyncio.coroutine +def test_api_get_components(hass, mock_api_client): + """Test the return of the components.""" + resp = yield from mock_api_client.get(const.URL_API_COMPONENTS) + result = yield from resp.json() + assert set(result) == hass.config.components + + +@asyncio.coroutine +def test_api_get_event_listeners(hass, mock_api_client): + """Test if we can get the list of events being listened for.""" + resp = yield from mock_api_client.get(const.URL_API_EVENTS) + data = yield from resp.json() + + local = hass.bus.async_listeners() + + for event in data: + assert local.pop(event["event"]) == event["listener_count"] + + assert len(local) == 0 + + +@asyncio.coroutine +def test_api_get_services(hass, mock_api_client): + """Test if we can get a dict describing current services.""" + resp = yield from mock_api_client.get(const.URL_API_SERVICES) + data = yield from resp.json() + local_services = hass.services.async_services() + + for serv_domain in data: + local = local_services.pop(serv_domain["domain"]) + + assert serv_domain["services"] == local + + +@asyncio.coroutine +def test_api_call_service_no_data(hass, mock_api_client): + """Test if the API allows us to call a service.""" + test_value = [] + + @ha.callback + def listener(service_call): + """Helper method that will verify that our service got called.""" + test_value.append(1) + + hass.services.async_register("test_domain", "test_service", listener) + + yield from mock_api_client.post( + const.URL_API_SERVICES_SERVICE.format( + "test_domain", "test_service")) + yield from hass.async_block_till_done() + assert len(test_value) == 1 + + +@asyncio.coroutine +def test_api_call_service_with_data(hass, mock_api_client): + """Test if the API allows us to call a service.""" + test_value = [] + + @ha.callback + def listener(service_call): + """Helper method that will verify that our service got called. + + Also test if our data came through. + """ + if "test" in service_call.data: test_value.append(1) - hass.bus.listen_once("test_event_bad_data", listener) + hass.services.async_register("test_domain", "test_service", listener) - req = requests.post( - _url(const.URL_API_EVENTS_EVENT.format("test_event_bad_data")), - data=json.dumps('not an object'), - headers=HA_HEADERS) + yield from mock_api_client.post( + const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service"), + json={"test": 1}) - hass.block_till_done() + yield from hass.async_block_till_done() + assert len(test_value) == 1 - self.assertEqual(400, req.status_code) - self.assertEqual(0, len(test_value)) - # Try now with valid but unusable JSON - req = requests.post( - _url(const.URL_API_EVENTS_EVENT.format("test_event_bad_data")), - data=json.dumps([1, 2, 3]), - headers=HA_HEADERS) +@asyncio.coroutine +def test_api_template(hass, mock_api_client): + """Test the template API.""" + hass.states.async_set('sensor.temperature', 10) - hass.block_till_done() + resp = yield from mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": '{{ states.sensor.temperature.state }}'}) - self.assertEqual(400, req.status_code) - self.assertEqual(0, len(test_value)) + body = yield from resp.text() - def test_api_get_config(self): - """Test the return of the configuration.""" - req = requests.get(_url(const.URL_API_CONFIG), - headers=HA_HEADERS) - result = req.json() - if 'components' in result: - result['components'] = set(result['components']) + assert body == '10' - self.assertEqual(hass.config.as_dict(), result) - def test_api_get_components(self): - """Test the return of the components.""" - req = requests.get(_url(const.URL_API_COMPONENTS), - headers=HA_HEADERS) - self.assertEqual(hass.config.components, set(req.json())) +@asyncio.coroutine +def test_api_template_error(hass, mock_api_client): + """Test the template API.""" + hass.states.async_set('sensor.temperature', 10) - def test_api_get_event_listeners(self): - """Test if we can get the list of events being listened for.""" - req = requests.get(_url(const.URL_API_EVENTS), - headers=HA_HEADERS) + resp = yield from mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": '{{ states.sensor.temperature.state'}) - local = hass.bus.listeners + assert resp.status == 400 - for event in req.json(): - self.assertEqual(event["listener_count"], - local.pop(event["event"])) - self.assertEqual(0, len(local)) +@asyncio.coroutine +def test_stream(hass, mock_api_client): + """Test the stream.""" + listen_count = _listen_count(hass) - def test_api_get_services(self): - """Test if we can get a dict describing current services.""" - req = requests.get(_url(const.URL_API_SERVICES), - headers=HA_HEADERS) + resp = yield from mock_api_client.get(const.URL_API_STREAM) + assert resp.status == 200 + assert listen_count + 1 == _listen_count(hass) - local_services = hass.services.services + hass.bus.async_fire('test_event') - for serv_domain in req.json(): - local = local_services.pop(serv_domain["domain"]) + data = yield from _stream_next_event(resp.content) - self.assertEqual(local, serv_domain["services"]) + assert data['event_type'] == 'test_event' - def test_api_call_service_no_data(self): - """Test if the API allows us to call a service.""" - test_value = [] - @ha.callback - def listener(service_call): - """Helper method that will verify that our service got called.""" - test_value.append(1) +@asyncio.coroutine +def test_stream_with_restricted(hass, mock_api_client): + """Test the stream with restrictions.""" + listen_count = _listen_count(hass) - hass.services.register("test_domain", "test_service", listener) + resp = yield from mock_api_client.get( + '{}?restrict=test_event1,test_event3'.format(const.URL_API_STREAM)) + assert resp.status == 200 + assert listen_count + 1 == _listen_count(hass) - requests.post( - _url(const.URL_API_SERVICES_SERVICE.format( - "test_domain", "test_service")), - headers=HA_HEADERS) + hass.bus.async_fire('test_event1') + data = yield from _stream_next_event(resp.content) + assert data['event_type'] == 'test_event1' - hass.block_till_done() + hass.bus.async_fire('test_event2') + hass.bus.async_fire('test_event3') + data = yield from _stream_next_event(resp.content) + assert data['event_type'] == 'test_event3' - self.assertEqual(1, len(test_value)) - def test_api_call_service_with_data(self): - """Test if the API allows us to call a service.""" - test_value = [] +@asyncio.coroutine +def _stream_next_event(stream): + """Read the stream for next event while ignoring ping.""" + while True: + last_new_line = False + data = b'' - @ha.callback - def listener(service_call): - """Helper method that will verify that our service got called. - - Also test if our data came through. - """ - if "test" in service_call.data: - test_value.append(1) - - hass.services.register("test_domain", "test_service", listener) - - requests.post( - _url(const.URL_API_SERVICES_SERVICE.format( - "test_domain", "test_service")), - data=json.dumps({"test": 1}), - headers=HA_HEADERS) - - hass.block_till_done() - - self.assertEqual(1, len(test_value)) - - def test_api_template(self): - """Test the template API.""" - hass.states.set('sensor.temperature', 10) - - req = requests.post( - _url(const.URL_API_TEMPLATE), - json={"template": '{{ states.sensor.temperature.state }}'}, - headers=HA_HEADERS) - - self.assertEqual('10', req.text) - - def test_api_template_error(self): - """Test the template API.""" - hass.states.set('sensor.temperature', 10) - - req = requests.post( - _url(const.URL_API_TEMPLATE), - data=json.dumps({"template": - '{{ states.sensor.temperature.state'}), - headers=HA_HEADERS) - - self.assertEqual(400, req.status_code) - - def test_stream(self): - """Test the stream.""" - listen_count = self._listen_count() - with closing(requests.get(_url(const.URL_API_STREAM), timeout=3, - stream=True, headers=HA_HEADERS)) as req: - stream = req.iter_content(1) - self.assertEqual(listen_count + 1, self._listen_count()) - - hass.bus.fire('test_event') - - data = self._stream_next_event(stream) - - self.assertEqual('test_event', data['event_type']) - - def test_stream_with_restricted(self): - """Test the stream with restrictions.""" - listen_count = self._listen_count() - url = _url('{}?restrict=test_event1,test_event3'.format( - const.URL_API_STREAM)) - with closing(requests.get(url, stream=True, timeout=3, - headers=HA_HEADERS)) as req: - stream = req.iter_content(1) - self.assertEqual(listen_count + 1, self._listen_count()) - - hass.bus.fire('test_event1') - data = self._stream_next_event(stream) - self.assertEqual('test_event1', data['event_type']) - - hass.bus.fire('test_event2') - hass.bus.fire('test_event3') - - data = self._stream_next_event(stream) - self.assertEqual('test_event3', data['event_type']) - - def _stream_next_event(self, stream): - """Read the stream for next event while ignoring ping.""" while True: - data = b'' - last_new_line = False - for dat in stream: - if dat == b'\n' and last_new_line: - break - data += dat - last_new_line = dat == b'\n' - - conv = data.decode('utf-8').strip()[6:] - - if conv != 'ping': + dat = yield from stream.read(1) + if dat == b'\n' and last_new_line: break + data += dat + last_new_line = dat == b'\n' - return json.loads(conv) + conv = data.decode('utf-8').strip()[6:] - def _listen_count(self): - """Return number of event listeners.""" - return sum(hass.bus.listeners.values()) + if conv != 'ping': + break + return json.loads(conv) + + +def _listen_count(hass): + """Return number of event listeners.""" + return sum(hass.bus.async_listeners().values()) diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index 5cd85a16a7a..3c73e85c4e5 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -1,7 +1,6 @@ """The tests device sun light trigger component.""" # pylint: disable=protected-access from datetime import datetime -import os import unittest from unittest.mock import patch @@ -12,32 +11,7 @@ from homeassistant.components import ( 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, fire_time_changed) - - -KNOWN_DEV_YAML_PATH = os.path.join(get_test_config_dir(), - device_tracker.YAML_DEVICES) - - -# pylint: disable=invalid-name -def setUpModule(): - """Write a device tracker known devices file to be used.""" - device_tracker.update_config( - KNOWN_DEV_YAML_PATH, 'device_1', device_tracker.Device( - None, None, True, 'device_1', 'DEV1', - picture='http://example.com/dev1.jpg')) - - device_tracker.update_config( - KNOWN_DEV_YAML_PATH, 'device_2', device_tracker.Device( - None, None, True, 'device_2', 'DEV2', - picture='http://example.com/dev2.jpg')) - - -# pylint: disable=invalid-name -def tearDownModule(): - """Remove device tracker known devices file.""" - os.remove(KNOWN_DEV_YAML_PATH) +from tests.common import get_test_home_assistant, fire_time_changed class TestDeviceSunLightTrigger(unittest.TestCase): @@ -55,9 +29,28 @@ class TestDeviceSunLightTrigger(unittest.TestCase): loader.get_component('light.test').init() - self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: {CONF_PLATFORM: 'test'} - })) + with patch( + 'homeassistant.components.device_tracker.load_yaml_config_file', + return_value={ + 'device_1': { + 'hide_if_away': False, + 'mac': 'DEV1', + 'name': 'Unnamed Device', + 'picture': 'http://example.com/dev1.jpg', + 'track': True, + 'vendor': None + }, + 'device_2': { + 'hide_if_away': False, + 'mac': 'DEV2', + 'name': 'Unnamed Device', + 'picture': 'http://example.com/dev2.jpg', + 'track': True, + 'vendor': None} + }): + self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: {CONF_PLATFORM: 'test'} + })) self.assertTrue(setup_component(self.hass, light.DOMAIN, { light.DOMAIN: {CONF_PLATFORM: 'test'} diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 952061be3c2..ce6fce03e83 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -8,10 +8,10 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def mock_http_client(loop, hass, test_client): +def mock_http_client(hass, test_client): """Start the Hass HTTP component.""" - loop.run_until_complete(async_setup_component(hass, 'frontend', {})) - return loop.run_until_complete(test_client(hass.http.app)) + hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {})) + return hass.loop.run_until_complete(test_client(hass.http.app)) @asyncio.coroutine From b5c54864ac18de11f65a21722e98da856aa53099 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 19 May 2017 16:39:13 +0200 Subject: [PATCH 012/105] Change line endings to LN (#7660) --- homeassistant/components/calendar/demo.py | 164 +++--- homeassistant/components/calendar/google.py | 156 +++--- homeassistant/components/camera/synology.py | 500 +++++++++--------- homeassistant/components/climate/tado.py | 2 +- homeassistant/components/remote/harmony.py | 374 ++++++------- .../components/sensor/synologydsm.py | 490 ++++++++--------- 6 files changed, 843 insertions(+), 843 deletions(-) diff --git a/homeassistant/components/calendar/demo.py b/homeassistant/components/calendar/demo.py index 9f6ad70b58f..7823f03c85e 100755 --- a/homeassistant/components/calendar/demo.py +++ b/homeassistant/components/calendar/demo.py @@ -1,82 +1,82 @@ -""" -Demo platform that has two fake binary sensors. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -import homeassistant.util.dt as dt_util -from homeassistant.components.calendar import CalendarEventDevice -from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Demo Calendar platform.""" - calendar_data_future = DemoGoogleCalendarDataFuture() - calendar_data_current = DemoGoogleCalendarDataCurrent() - add_devices([ - DemoGoogleCalendar(hass, calendar_data_future, { - CONF_NAME: 'Future Event', - CONF_DEVICE_ID: 'future_event', - }), - - DemoGoogleCalendar(hass, calendar_data_current, { - CONF_NAME: 'Current Event', - CONF_DEVICE_ID: 'current_event', - }), - ]) - - -class DemoGoogleCalendarData(object): - """Representation of a Demo Calendar element.""" - - # pylint: disable=no-self-use - def update(self): - """Return true so entity knows we have new data.""" - return True - - -class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): - """Representation of a Demo Calendar for a future event.""" - - def __init__(self): - """Set the event to a future event.""" - one_hour_from_now = dt_util.now() \ - + dt_util.dt.timedelta(minutes=30) - self.event = { - 'start': { - 'dateTime': one_hour_from_now.isoformat() - }, - 'end': { - 'dateTime': (one_hour_from_now + dt_util.dt. - timedelta(minutes=60)).isoformat() - }, - 'summary': 'Future Event', - } - - -class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData): - """Representation of a Demo Calendar for a current event.""" - - def __init__(self): - """Set the event data.""" - middle_of_event = dt_util.now() \ - - dt_util.dt.timedelta(minutes=30) - self.event = { - 'start': { - 'dateTime': middle_of_event.isoformat() - }, - 'end': { - 'dateTime': (middle_of_event + dt_util.dt. - timedelta(minutes=60)).isoformat() - }, - 'summary': 'Current Event', - } - - -class DemoGoogleCalendar(CalendarEventDevice): - """Representation of a Demo Calendar element.""" - - def __init__(self, hass, calendar_data, data): - """Initialize Google Calendar but without the API calls.""" - self.data = calendar_data - super().__init__(hass, data) +""" +Demo platform that has two fake binary sensors. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +import homeassistant.util.dt as dt_util +from homeassistant.components.calendar import CalendarEventDevice +from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Demo Calendar platform.""" + calendar_data_future = DemoGoogleCalendarDataFuture() + calendar_data_current = DemoGoogleCalendarDataCurrent() + add_devices([ + DemoGoogleCalendar(hass, calendar_data_future, { + CONF_NAME: 'Future Event', + CONF_DEVICE_ID: 'future_event', + }), + + DemoGoogleCalendar(hass, calendar_data_current, { + CONF_NAME: 'Current Event', + CONF_DEVICE_ID: 'current_event', + }), + ]) + + +class DemoGoogleCalendarData(object): + """Representation of a Demo Calendar element.""" + + # pylint: disable=no-self-use + def update(self): + """Return true so entity knows we have new data.""" + return True + + +class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): + """Representation of a Demo Calendar for a future event.""" + + def __init__(self): + """Set the event to a future event.""" + one_hour_from_now = dt_util.now() \ + + dt_util.dt.timedelta(minutes=30) + self.event = { + 'start': { + 'dateTime': one_hour_from_now.isoformat() + }, + 'end': { + 'dateTime': (one_hour_from_now + dt_util.dt. + timedelta(minutes=60)).isoformat() + }, + 'summary': 'Future Event', + } + + +class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData): + """Representation of a Demo Calendar for a current event.""" + + def __init__(self): + """Set the event data.""" + middle_of_event = dt_util.now() \ + - dt_util.dt.timedelta(minutes=30) + self.event = { + 'start': { + 'dateTime': middle_of_event.isoformat() + }, + 'end': { + 'dateTime': (middle_of_event + dt_util.dt. + timedelta(minutes=60)).isoformat() + }, + 'summary': 'Current Event', + } + + +class DemoGoogleCalendar(CalendarEventDevice): + """Representation of a Demo Calendar element.""" + + def __init__(self, hass, calendar_data, data): + """Initialize Google Calendar but without the API calls.""" + self.data = calendar_data + super().__init__(hass, data) diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index 26c2c251afb..362202d1bde 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -1,78 +1,78 @@ -""" -Support for Google Calendar Search binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.google_calendar/ -""" -# pylint: disable=import-error -import logging -from datetime import timedelta - -from homeassistant.components.calendar import CalendarEventDevice -from homeassistant.components.google import ( - CONF_CAL_ID, CONF_ENTITIES, CONF_TRACK, TOKEN_FILE, - GoogleCalendarService) -from homeassistant.util import Throttle, dt - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_GOOGLE_SEARCH_PARAMS = { - 'orderBy': 'startTime', - 'maxResults': 1, - 'singleEvents': True, -} - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - - -def setup_platform(hass, config, add_devices, disc_info=None): - """Set up the calendar platform for event devices.""" - if disc_info is None: - return - - if not any([data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]]): - return - - calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) - add_devices([GoogleCalendarEventDevice(hass, calendar_service, - disc_info[CONF_CAL_ID], data) - for data in disc_info[CONF_ENTITIES] if data[CONF_TRACK]]) - - -# pylint: disable=too-many-instance-attributes -class GoogleCalendarEventDevice(CalendarEventDevice): - """A calendar event device.""" - - def __init__(self, hass, calendar_service, calendar, data): - """Create the Calendar event device.""" - self.data = GoogleCalendarData(calendar_service, calendar, - data.get('search', None)) - super().__init__(hass, data) - - -class GoogleCalendarData(object): - """Class to utilize calendar service object to get next event.""" - - def __init__(self, calendar_service, calendar_id, search=None): - """Set up how we are going to search the google calendar.""" - self.calendar_service = calendar_service - self.calendar_id = calendar_id - self.search = search - self.event = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data.""" - service = self.calendar_service.get() - params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) - params['timeMin'] = dt.now().isoformat('T') - params['calendarId'] = self.calendar_id - if self.search: - params['q'] = self.search - - events = service.events() # pylint: disable=no-member - result = events.list(**params).execute() - - items = result.get('items', []) - self.event = items[0] if len(items) == 1 else None - return True +""" +Support for Google Calendar Search binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.google_calendar/ +""" +# pylint: disable=import-error +import logging +from datetime import timedelta + +from homeassistant.components.calendar import CalendarEventDevice +from homeassistant.components.google import ( + CONF_CAL_ID, CONF_ENTITIES, CONF_TRACK, TOKEN_FILE, + GoogleCalendarService) +from homeassistant.util import Throttle, dt + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_GOOGLE_SEARCH_PARAMS = { + 'orderBy': 'startTime', + 'maxResults': 1, + 'singleEvents': True, +} + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, disc_info=None): + """Set up the calendar platform for event devices.""" + if disc_info is None: + return + + if not any([data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]]): + return + + calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) + add_devices([GoogleCalendarEventDevice(hass, calendar_service, + disc_info[CONF_CAL_ID], data) + for data in disc_info[CONF_ENTITIES] if data[CONF_TRACK]]) + + +# pylint: disable=too-many-instance-attributes +class GoogleCalendarEventDevice(CalendarEventDevice): + """A calendar event device.""" + + def __init__(self, hass, calendar_service, calendar, data): + """Create the Calendar event device.""" + self.data = GoogleCalendarData(calendar_service, calendar, + data.get('search', None)) + super().__init__(hass, data) + + +class GoogleCalendarData(object): + """Class to utilize calendar service object to get next event.""" + + def __init__(self, calendar_service, calendar_id, search=None): + """Set up how we are going to search the google calendar.""" + self.calendar_service = calendar_service + self.calendar_id = calendar_id + self.search = search + self.event = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + service = self.calendar_service.get() + params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) + params['timeMin'] = dt.now().isoformat('T') + params['calendarId'] = self.calendar_id + if self.search: + params['q'] = self.search + + events = service.events() # pylint: disable=no-member + result = events.list(**params).execute() + + items = result.get('items', []) + self.event = items[0] if len(items) == 1 else None + return True diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py index de61535b336..90dfa58d8c5 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -1,250 +1,250 @@ -""" -Support for Synology Surveillance Station Cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.synology/ -""" -import asyncio -import logging - -import voluptuous as vol - -import aiohttp -import async_timeout - -from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT) -from homeassistant.components.camera import ( - Camera, PLATFORM_SCHEMA) -from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_create_clientsession, - async_aiohttp_proxy_web) -import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'Synology Camera' -DEFAULT_STREAM_ID = '0' -DEFAULT_TIMEOUT = 5 -CONF_CAMERA_NAME = 'camera_name' -CONF_STREAM_ID = 'stream_id' - -QUERY_CGI = 'query.cgi' -QUERY_API = 'SYNO.API.Info' -AUTH_API = 'SYNO.API.Auth' -CAMERA_API = 'SYNO.SurveillanceStation.Camera' -STREAMING_API = 'SYNO.SurveillanceStation.VideoStream' -SESSION_ID = '0' - -WEBAPI_PATH = '/webapi/' -AUTH_PATH = 'auth.cgi' -CAMERA_PATH = 'camera.cgi' -STREAMING_PATH = 'SurveillanceStation/videoStreaming.cgi' -CONTENT_TYPE_HEADER = 'Content-Type' - -SYNO_API_URL = '{0}{1}{2}' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_WHITELIST, default=[]): cv.ensure_list, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up a Synology IP Camera.""" - verify_ssl = config.get(CONF_VERIFY_SSL) - timeout = config.get(CONF_TIMEOUT) - websession_init = async_get_clientsession(hass, verify_ssl) - - # Determine API to use for authentication - syno_api_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, QUERY_CGI) - - query_payload = { - 'api': QUERY_API, - 'method': 'Query', - 'version': '1', - 'query': 'SYNO.' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - query_req = yield from websession_init.get( - syno_api_url, - params=query_payload - ) - - # Skip content type check because Synology doesn't return JSON with - # right content type - query_resp = yield from query_req.json(content_type=None) - auth_path = query_resp['data'][AUTH_API]['path'] - camera_api = query_resp['data'][CAMERA_API]['path'] - camera_path = query_resp['data'][CAMERA_API]['path'] - streaming_path = query_resp['data'][STREAMING_API]['path'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_api_url) - return False - - # Authticate to NAS to get a session id - syno_auth_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, auth_path) - - session_id = yield from get_session_id( - hass, - websession_init, - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - syno_auth_url, - timeout - ) - - # init websession - websession = async_create_clientsession( - hass, verify_ssl, cookies={'id': session_id}) - - # Use SessionID to get cameras in system - syno_camera_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, camera_api) - - camera_payload = { - 'api': CAMERA_API, - 'method': 'List', - 'version': '1' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - camera_req = yield from websession.get( - syno_camera_url, - params=camera_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_camera_url) - return False - - camera_resp = yield from camera_req.json(content_type=None) - cameras = camera_resp['data']['cameras'] - - # add cameras - devices = [] - for camera in cameras: - if not config.get(CONF_WHITELIST): - camera_id = camera['id'] - snapshot_path = camera['snapshot_path'] - - device = SynologyCamera( - hass, websession, config, camera_id, camera['name'], - snapshot_path, streaming_path, camera_path, auth_path, timeout - ) - devices.append(device) - - async_add_devices(devices) - - -@asyncio.coroutine -def get_session_id(hass, websession, username, password, login_url, timeout): - """Get a session id.""" - auth_payload = { - 'api': AUTH_API, - 'method': 'Login', - 'version': '2', - 'account': username, - 'passwd': password, - 'session': 'SurveillanceStation', - 'format': 'sid' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - auth_req = yield from websession.get( - login_url, - params=auth_payload - ) - auth_resp = yield from auth_req.json(content_type=None) - return auth_resp['data']['sid'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", login_url) - return False - - -class SynologyCamera(Camera): - """An implementation of a Synology NAS based IP camera.""" - - def __init__(self, hass, websession, config, camera_id, - camera_name, snapshot_path, streaming_path, camera_path, - auth_path, timeout): - """Initialize a Synology Surveillance Station camera.""" - super().__init__() - self.hass = hass - self._websession = websession - self._name = camera_name - self._synology_url = config.get(CONF_URL) - self._camera_name = config.get(CONF_CAMERA_NAME) - self._stream_id = config.get(CONF_STREAM_ID) - self._camera_id = camera_id - self._snapshot_path = snapshot_path - self._streaming_path = streaming_path - self._camera_path = camera_path - self._auth_path = auth_path - self._timeout = timeout - - def camera_image(self): - """Return bytes of camera image.""" - return run_coroutine_threadsafe( - self.async_camera_image(), self.hass.loop).result() - - @asyncio.coroutine - def async_camera_image(self): - """Return a still image response from the camera.""" - image_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._camera_path) - - image_payload = { - 'api': CAMERA_API, - 'method': 'GetSnapshot', - 'version': '1', - 'cameraId': self._camera_id - } - try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - response = yield from self._websession.get( - image_url, - params=image_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error fetching %s", image_url) - return None - - image = yield from response.read() - - return image - - @asyncio.coroutine - def handle_async_mjpeg_stream(self, request): - """Return a MJPEG stream image response directly from the camera.""" - streaming_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._streaming_path) - - streaming_payload = { - 'api': STREAMING_API, - 'method': 'Stream', - 'version': '1', - 'cameraId': self._camera_id, - 'format': 'mjpeg' - } - stream_coro = self._websession.get( - streaming_url, params=streaming_payload) - - yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) - - @property - def name(self): - """Return the name of this device.""" - return self._name +""" +Support for Synology Surveillance Station Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.synology/ +""" +import asyncio +import logging + +import voluptuous as vol + +import aiohttp +import async_timeout + +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT) +from homeassistant.components.camera import ( + Camera, PLATFORM_SCHEMA) +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_create_clientsession, + async_aiohttp_proxy_web) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.async import run_coroutine_threadsafe + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Synology Camera' +DEFAULT_STREAM_ID = '0' +DEFAULT_TIMEOUT = 5 +CONF_CAMERA_NAME = 'camera_name' +CONF_STREAM_ID = 'stream_id' + +QUERY_CGI = 'query.cgi' +QUERY_API = 'SYNO.API.Info' +AUTH_API = 'SYNO.API.Auth' +CAMERA_API = 'SYNO.SurveillanceStation.Camera' +STREAMING_API = 'SYNO.SurveillanceStation.VideoStream' +SESSION_ID = '0' + +WEBAPI_PATH = '/webapi/' +AUTH_PATH = 'auth.cgi' +CAMERA_PATH = 'camera.cgi' +STREAMING_PATH = 'SurveillanceStation/videoStreaming.cgi' +CONTENT_TYPE_HEADER = 'Content-Type' + +SYNO_API_URL = '{0}{1}{2}' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_WHITELIST, default=[]): cv.ensure_list, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up a Synology IP Camera.""" + verify_ssl = config.get(CONF_VERIFY_SSL) + timeout = config.get(CONF_TIMEOUT) + websession_init = async_get_clientsession(hass, verify_ssl) + + # Determine API to use for authentication + syno_api_url = SYNO_API_URL.format( + config.get(CONF_URL), WEBAPI_PATH, QUERY_CGI) + + query_payload = { + 'api': QUERY_API, + 'method': 'Query', + 'version': '1', + 'query': 'SYNO.' + } + try: + with async_timeout.timeout(timeout, loop=hass.loop): + query_req = yield from websession_init.get( + syno_api_url, + params=query_payload + ) + + # Skip content type check because Synology doesn't return JSON with + # right content type + query_resp = yield from query_req.json(content_type=None) + auth_path = query_resp['data'][AUTH_API]['path'] + camera_api = query_resp['data'][CAMERA_API]['path'] + camera_path = query_resp['data'][CAMERA_API]['path'] + streaming_path = query_resp['data'][STREAMING_API]['path'] + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.exception("Error on %s", syno_api_url) + return False + + # Authticate to NAS to get a session id + syno_auth_url = SYNO_API_URL.format( + config.get(CONF_URL), WEBAPI_PATH, auth_path) + + session_id = yield from get_session_id( + hass, + websession_init, + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + syno_auth_url, + timeout + ) + + # init websession + websession = async_create_clientsession( + hass, verify_ssl, cookies={'id': session_id}) + + # Use SessionID to get cameras in system + syno_camera_url = SYNO_API_URL.format( + config.get(CONF_URL), WEBAPI_PATH, camera_api) + + camera_payload = { + 'api': CAMERA_API, + 'method': 'List', + 'version': '1' + } + try: + with async_timeout.timeout(timeout, loop=hass.loop): + camera_req = yield from websession.get( + syno_camera_url, + params=camera_payload + ) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.exception("Error on %s", syno_camera_url) + return False + + camera_resp = yield from camera_req.json(content_type=None) + cameras = camera_resp['data']['cameras'] + + # add cameras + devices = [] + for camera in cameras: + if not config.get(CONF_WHITELIST): + camera_id = camera['id'] + snapshot_path = camera['snapshot_path'] + + device = SynologyCamera( + hass, websession, config, camera_id, camera['name'], + snapshot_path, streaming_path, camera_path, auth_path, timeout + ) + devices.append(device) + + async_add_devices(devices) + + +@asyncio.coroutine +def get_session_id(hass, websession, username, password, login_url, timeout): + """Get a session id.""" + auth_payload = { + 'api': AUTH_API, + 'method': 'Login', + 'version': '2', + 'account': username, + 'passwd': password, + 'session': 'SurveillanceStation', + 'format': 'sid' + } + try: + with async_timeout.timeout(timeout, loop=hass.loop): + auth_req = yield from websession.get( + login_url, + params=auth_payload + ) + auth_resp = yield from auth_req.json(content_type=None) + return auth_resp['data']['sid'] + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.exception("Error on %s", login_url) + return False + + +class SynologyCamera(Camera): + """An implementation of a Synology NAS based IP camera.""" + + def __init__(self, hass, websession, config, camera_id, + camera_name, snapshot_path, streaming_path, camera_path, + auth_path, timeout): + """Initialize a Synology Surveillance Station camera.""" + super().__init__() + self.hass = hass + self._websession = websession + self._name = camera_name + self._synology_url = config.get(CONF_URL) + self._camera_name = config.get(CONF_CAMERA_NAME) + self._stream_id = config.get(CONF_STREAM_ID) + self._camera_id = camera_id + self._snapshot_path = snapshot_path + self._streaming_path = streaming_path + self._camera_path = camera_path + self._auth_path = auth_path + self._timeout = timeout + + def camera_image(self): + """Return bytes of camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + image_url = SYNO_API_URL.format( + self._synology_url, WEBAPI_PATH, self._camera_path) + + image_payload = { + 'api': CAMERA_API, + 'method': 'GetSnapshot', + 'version': '1', + 'cameraId': self._camera_id + } + try: + with async_timeout.timeout(self._timeout, loop=self.hass.loop): + response = yield from self._websession.get( + image_url, + params=image_payload + ) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Error fetching %s", image_url) + return None + + image = yield from response.read() + + return image + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Return a MJPEG stream image response directly from the camera.""" + streaming_url = SYNO_API_URL.format( + self._synology_url, WEBAPI_PATH, self._streaming_path) + + streaming_payload = { + 'api': STREAMING_API, + 'method': 'Stream', + 'version': '1', + 'cameraId': self._camera_id, + 'format': 'mjpeg' + } + stream_coro = self._websession.get( + streaming_url, params=streaming_payload) + + yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) + + @property + def name(self): + """Return the name of this device.""" + return self._name diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index 7bc99de26f9..af9ad44fd7e 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -1,4 +1,4 @@ -""" +""" Tado component to create a climate device for each zone. For more details about this platform, please refer to the documentation at diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index e0c01023660..5a1e31bd0df 100755 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -1,187 +1,187 @@ -""" -Support for Harmony Hub devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/remote.harmony/ -""" -import logging -from os import path -import urllib.parse - -import voluptuous as vol - -import homeassistant.components.remote as remote -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID) -from homeassistant.components.remote import ( - PLATFORM_SCHEMA, DOMAIN, ATTR_DEVICE, ATTR_COMMAND, ATTR_ACTIVITY) -from homeassistant.util import slugify -from homeassistant.config import load_yaml_config_file - -REQUIREMENTS = ['pyharmony==1.0.12'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_PORT = 5222 -DEVICES = [] - -SERVICE_SYNC = 'harmony_sync' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(ATTR_ACTIVITY, default=None): cv.string, -}) - -HARMONY_SYNC_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Harmony platform.""" - import pyharmony - global DEVICES - - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - _LOGGER.debug("Loading Harmony platform: %s", name) - - harmony_conf_file = hass.config.path( - '{}{}{}'.format('harmony_', slugify(name), '.conf')) - - try: - _LOGGER.debug("Calling pyharmony.ha_get_token for remote at: %s:%s", - host, port) - token = urllib.parse.quote_plus(pyharmony.ha_get_token(host, port)) - except ValueError as err: - _LOGGER.warning("%s for remote: %s", err.args[0], name) - return False - - _LOGGER.debug("Received token: %s", token) - DEVICES = [HarmonyRemote( - config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), - config.get(ATTR_ACTIVITY), harmony_conf_file, token)] - add_devices(DEVICES, True) - register_services(hass) - return True - - -def register_services(hass): - """Register all services for harmony devices.""" - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - - hass.services.register( - DOMAIN, SERVICE_SYNC, _sync_service, descriptions.get(SERVICE_SYNC), - schema=HARMONY_SYNC_SCHEMA) - - -def _apply_service(service, service_func, *service_func_args): - """Handle services to apply.""" - entity_ids = service.data.get('entity_id') - - if entity_ids: - _devices = [device for device in DEVICES - if device.entity_id in entity_ids] - else: - _devices = DEVICES - - for device in _devices: - service_func(device, *service_func_args) - device.schedule_update_ha_state(True) - - -def _sync_service(service): - _apply_service(service, HarmonyRemote.sync) - - -class HarmonyRemote(remote.RemoteDevice): - """Remote representation used to control a Harmony device.""" - - def __init__(self, name, host, port, activity, out_path, token): - """Initialize HarmonyRemote class.""" - import pyharmony - from pathlib import Path - - _LOGGER.debug("HarmonyRemote device init started for: %s", name) - self._name = name - self._ip = host - self._port = port - self._state = None - self._current_activity = None - self._default_activity = activity - self._token = token - self._config_path = out_path - _LOGGER.debug("Retrieving harmony config using token: %s", token) - self._config = pyharmony.ha_get_config(self._token, host, port) - if not Path(self._config_path).is_file(): - _LOGGER.debug("Writing harmony configuration to file: %s", - out_path) - pyharmony.ha_write_config_file(self._config, self._config_path) - - @property - def name(self): - """Return the Harmony device's name.""" - return self._name - - @property - def device_state_attributes(self): - """Add platform specific attributes.""" - return {'current_activity': self._current_activity} - - @property - def is_on(self): - """Return False if PowerOff is the current activity, otherwise True.""" - return self._current_activity != 'PowerOff' - - def update(self): - """Return current activity.""" - import pyharmony - name = self._name - _LOGGER.debug("Polling %s for current activity", name) - state = pyharmony.ha_get_current_activity( - self._token, self._config, self._ip, self._port) - _LOGGER.debug("%s current activity reported as: %s", name, state) - self._current_activity = state - self._state = bool(state != 'PowerOff') - - def turn_on(self, **kwargs): - """Start an activity from the Harmony device.""" - import pyharmony - if kwargs[ATTR_ACTIVITY]: - activity = kwargs[ATTR_ACTIVITY] - else: - activity = self._default_activity - - if activity: - pyharmony.ha_start_activity( - self._token, self._ip, self._port, self._config, activity) - self._state = True - else: - _LOGGER.error("No activity specified with turn_on service") - - def turn_off(self): - """Start the PowerOff activity.""" - import pyharmony - pyharmony.ha_power_off(self._token, self._ip, self._port) - - def send_command(self, **kwargs): - """Send a command to one device.""" - import pyharmony - pyharmony.ha_send_command( - self._token, self._ip, self._port, kwargs[ATTR_DEVICE], - kwargs[ATTR_COMMAND]) - - def sync(self): - """Sync the Harmony device with the web service.""" - import pyharmony - _LOGGER.debug("Syncing hub with Harmony servers") - pyharmony.ha_sync(self._token, self._ip, self._port) - self._config = pyharmony.ha_get_config( - self._token, self._ip, self._port) - _LOGGER.debug("Writing hub config to file: %s", self._config_path) - pyharmony.ha_write_config_file(self._config, self._config_path) +""" +Support for Harmony Hub devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/remote.harmony/ +""" +import logging +from os import path +import urllib.parse + +import voluptuous as vol + +import homeassistant.components.remote as remote +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID) +from homeassistant.components.remote import ( + PLATFORM_SCHEMA, DOMAIN, ATTR_DEVICE, ATTR_COMMAND, ATTR_ACTIVITY) +from homeassistant.util import slugify +from homeassistant.config import load_yaml_config_file + +REQUIREMENTS = ['pyharmony==1.0.12'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 5222 +DEVICES = [] + +SERVICE_SYNC = 'harmony_sync' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(ATTR_ACTIVITY, default=None): cv.string, +}) + +HARMONY_SYNC_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Harmony platform.""" + import pyharmony + global DEVICES + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + _LOGGER.debug("Loading Harmony platform: %s", name) + + harmony_conf_file = hass.config.path( + '{}{}{}'.format('harmony_', slugify(name), '.conf')) + + try: + _LOGGER.debug("Calling pyharmony.ha_get_token for remote at: %s:%s", + host, port) + token = urllib.parse.quote_plus(pyharmony.ha_get_token(host, port)) + except ValueError as err: + _LOGGER.warning("%s for remote: %s", err.args[0], name) + return False + + _LOGGER.debug("Received token: %s", token) + DEVICES = [HarmonyRemote( + config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), + config.get(ATTR_ACTIVITY), harmony_conf_file, token)] + add_devices(DEVICES, True) + register_services(hass) + return True + + +def register_services(hass): + """Register all services for harmony devices.""" + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register( + DOMAIN, SERVICE_SYNC, _sync_service, descriptions.get(SERVICE_SYNC), + schema=HARMONY_SYNC_SCHEMA) + + +def _apply_service(service, service_func, *service_func_args): + """Handle services to apply.""" + entity_ids = service.data.get('entity_id') + + if entity_ids: + _devices = [device for device in DEVICES + if device.entity_id in entity_ids] + else: + _devices = DEVICES + + for device in _devices: + service_func(device, *service_func_args) + device.schedule_update_ha_state(True) + + +def _sync_service(service): + _apply_service(service, HarmonyRemote.sync) + + +class HarmonyRemote(remote.RemoteDevice): + """Remote representation used to control a Harmony device.""" + + def __init__(self, name, host, port, activity, out_path, token): + """Initialize HarmonyRemote class.""" + import pyharmony + from pathlib import Path + + _LOGGER.debug("HarmonyRemote device init started for: %s", name) + self._name = name + self._ip = host + self._port = port + self._state = None + self._current_activity = None + self._default_activity = activity + self._token = token + self._config_path = out_path + _LOGGER.debug("Retrieving harmony config using token: %s", token) + self._config = pyharmony.ha_get_config(self._token, host, port) + if not Path(self._config_path).is_file(): + _LOGGER.debug("Writing harmony configuration to file: %s", + out_path) + pyharmony.ha_write_config_file(self._config, self._config_path) + + @property + def name(self): + """Return the Harmony device's name.""" + return self._name + + @property + def device_state_attributes(self): + """Add platform specific attributes.""" + return {'current_activity': self._current_activity} + + @property + def is_on(self): + """Return False if PowerOff is the current activity, otherwise True.""" + return self._current_activity != 'PowerOff' + + def update(self): + """Return current activity.""" + import pyharmony + name = self._name + _LOGGER.debug("Polling %s for current activity", name) + state = pyharmony.ha_get_current_activity( + self._token, self._config, self._ip, self._port) + _LOGGER.debug("%s current activity reported as: %s", name, state) + self._current_activity = state + self._state = bool(state != 'PowerOff') + + def turn_on(self, **kwargs): + """Start an activity from the Harmony device.""" + import pyharmony + if kwargs[ATTR_ACTIVITY]: + activity = kwargs[ATTR_ACTIVITY] + else: + activity = self._default_activity + + if activity: + pyharmony.ha_start_activity( + self._token, self._ip, self._port, self._config, activity) + self._state = True + else: + _LOGGER.error("No activity specified with turn_on service") + + def turn_off(self): + """Start the PowerOff activity.""" + import pyharmony + pyharmony.ha_power_off(self._token, self._ip, self._port) + + def send_command(self, **kwargs): + """Send a command to one device.""" + import pyharmony + pyharmony.ha_send_command( + self._token, self._ip, self._port, kwargs[ATTR_DEVICE], + kwargs[ATTR_COMMAND]) + + def sync(self): + """Sync the Harmony device with the web service.""" + import pyharmony + _LOGGER.debug("Syncing hub with Harmony servers") + pyharmony.ha_sync(self._token, self._ip, self._port) + self._config = pyharmony.ha_get_config( + self._token, self._ip, self._port) + _LOGGER.debug("Writing hub config to file: %s", self._config_path) + pyharmony.ha_write_config_file(self._config, self._config_path) diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index b2bcea02c00..eb31381ccef 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -1,245 +1,245 @@ -""" -Support for Synology NAS Sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.synologydsm/ -""" -import logging -from datetime import timedelta - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers.entity import Entity -from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, - CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, EVENT_HOMEASSISTANT_START) -from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv - -import voluptuous as vol - -REQUIREMENTS = ['python-synology==0.1.0'] - -_LOGGER = logging.getLogger(__name__) - -CONF_DISKS = 'disks' -CONF_VOLUMES = 'volumes' -DEFAULT_NAME = 'Synology DSM' -DEFAULT_PORT = 5000 - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - -_UTILISATION_MON_COND = { - 'cpu_other_load': ['CPU Load (Other)', '%', 'mdi:chip'], - 'cpu_user_load': ['CPU Load (User)', '%', 'mdi:chip'], - 'cpu_system_load': ['CPU Load (System)', '%', 'mdi:chip'], - 'cpu_total_load': ['CPU Load (Total)', '%', 'mdi:chip'], - 'cpu_1min_load': ['CPU Load (1 min)', '%', 'mdi:chip'], - 'cpu_5min_load': ['CPU Load (5 min)', '%', 'mdi:chip'], - 'cpu_15min_load': ['CPU Load (15 min)', '%', 'mdi:chip'], - 'memory_real_usage': ['Memory Usage (Real)', '%', 'mdi:memory'], - 'memory_size': ['Memory Size', 'Mb', 'mdi:memory'], - 'memory_cached': ['Memory Cached', 'Mb', 'mdi:memory'], - 'memory_available_swap': ['Memory Available (Swap)', 'Mb', 'mdi:memory'], - 'memory_available_real': ['Memory Available (Real)', 'Mb', 'mdi:memory'], - 'memory_total_swap': ['Memory Total (Swap)', 'Mb', 'mdi:memory'], - 'memory_total_real': ['Memory Total (Real)', 'Mb', 'mdi:memory'], - 'network_up': ['Network Up', 'Kbps', 'mdi:upload'], - 'network_down': ['Network Down', 'Kbps', 'mdi:download'], -} -_STORAGE_VOL_MON_COND = { - 'volume_status': ['Status', None, 'mdi:checkbox-marked-circle-outline'], - 'volume_device_type': ['Type', None, 'mdi:harddisk'], - 'volume_size_total': ['Total Size', None, 'mdi:chart-pie'], - 'volume_size_used': ['Used Space', None, 'mdi:chart-pie'], - 'volume_percentage_used': ['Volume Used', '%', 'mdi:chart-pie'], - 'volume_disk_temp_avg': ['Average Disk Temp', None, 'mdi:thermometer'], - 'volume_disk_temp_max': ['Maximum Disk Temp', None, 'mdi:thermometer'], -} -_STORAGE_DSK_MON_COND = { - 'disk_name': ['Name', None, 'mdi:harddisk'], - 'disk_device': ['Device', None, 'mdi:dots-horizontal'], - 'disk_smart_status': ['Status (Smart)', None, - 'mdi:checkbox-marked-circle-outline'], - 'disk_status': ['Status', None, 'mdi:checkbox-marked-circle-outline'], - 'disk_exceed_bad_sector_thr': ['Exceeded Max Bad Sectors', None, - 'mdi:test-tube'], - 'disk_below_remain_life_thr': ['Below Min Remaining Life', None, - 'mdi:test-tube'], - 'disk_temp': ['Temperature', None, 'mdi:thermometer'], -} - -_MONITORED_CONDITIONS = list(_UTILISATION_MON_COND.keys()) + \ - list(_STORAGE_VOL_MON_COND.keys()) + \ - list(_STORAGE_DSK_MON_COND.keys()) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]), - vol.Optional(CONF_DISKS, default=None): cv.ensure_list, - vol.Optional(CONF_VOLUMES, default=None): cv.ensure_list, -}) - - -def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Set up the Synology NAS Sensor.""" - # pylint: disable=too-many-locals - def run_setup(event): - """Wait until HASS is fully initialized before creating. - - Delay the setup until Home Assistant is fully initialized. - This allows any entities to be created already - """ - # Setup API - api = SynoApi(config.get(CONF_HOST), config.get(CONF_PORT), - config.get(CONF_USERNAME), config.get(CONF_PASSWORD), - hass.config.units.temperature_unit) - - sensors = [SynoNasUtilSensor(api, variable, - _UTILISATION_MON_COND[variable]) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _UTILISATION_MON_COND] - - # Handle all Volumes - volumes = config['volumes'] - if volumes is None: - volumes = api.storage.volumes - - for volume in volumes: - sensors += [SynoNasStorageSensor(api, variable, - _STORAGE_VOL_MON_COND[variable], - volume) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _STORAGE_VOL_MON_COND] - - # Handle all Disks - disks = config['disks'] - if disks is None: - disks = api.storage.disks - - for disk in disks: - sensors += [SynoNasStorageSensor(api, variable, - _STORAGE_DSK_MON_COND[variable], - disk) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _STORAGE_DSK_MON_COND] - - add_devices_callback(sensors) - - # Wait until start event is sent to load this component. - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) - - -class SynoApi(): - """Class to interface with API.""" - - # pylint: disable=too-many-arguments, bare-except - def __init__(self, host, port, username, password, temp_unit): - """Initialize the API wrapper class.""" - from SynologyDSM import SynologyDSM - self.temp_unit = temp_unit - - try: - self._api = SynologyDSM(host, - port, - username, - password) - except: - _LOGGER.error("Error setting up Synology DSM") - - # Will be updated when `update` gets called. - self.utilisation = self._api.utilisation - self.storage = self._api.storage - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update function for updating api information.""" - self._api.update() - - -class SynoNasSensor(Entity): - """Representation of a Synology Nas Sensor.""" - - def __init__(self, api, variable, variableInfo, monitor_device=None): - """Initialize the sensor.""" - self.var_id = variable - self.var_name = variableInfo[0] - self.var_units = variableInfo[1] - self.var_icon = variableInfo[2] - self.monitor_device = monitor_device - self._api = api - - @property - def name(self): - """Return the name of the sensor, if any.""" - if self.monitor_device is not None: - return "{} ({})".format(self.var_name, self.monitor_device) - else: - return self.var_name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self.var_icon - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - if self.var_id in ['volume_disk_temp_avg', 'volume_disk_temp_max', - 'disk_temp']: - return self._api.temp_unit - else: - return self.var_units - - def update(self): - """Get the latest data for the states.""" - if self._api is not None: - self._api.update() - - -class SynoNasUtilSensor(SynoNasSensor): - """Representation a Synology Utilisation Sensor.""" - - @property - def state(self): - """Return the state of the sensor.""" - network_sensors = ['network_up', 'network_down'] - memory_sensors = ['memory_size', 'memory_cached', - 'memory_available_swap', 'memory_available_real', - 'memory_total_swap', 'memory_total_real'] - - if self.var_id in network_sensors or self.var_id in memory_sensors: - attr = getattr(self._api.utilisation, self.var_id)(False) - - if self.var_id in network_sensors: - return round(attr / 1024.0, 1) - elif self.var_id in memory_sensors: - return round(attr / 1024.0 / 1024.0, 1) - else: - return getattr(self._api.utilisation, self.var_id) - - -class SynoNasStorageSensor(SynoNasSensor): - """Representation a Synology Utilisation Sensor.""" - - @property - def state(self): - """Return the state of the sensor.""" - temp_sensors = ['volume_disk_temp_avg', 'volume_disk_temp_max', - 'disk_temp'] - - if self.monitor_device is not None: - if self.var_id in temp_sensors: - attr = getattr(self._api.storage, - self.var_id)(self.monitor_device) - - if self._api.temp_unit == TEMP_CELSIUS: - return attr - else: - return round(attr * 1.8 + 32.0, 1) - else: - return getattr(self._api.storage, - self.var_id)(self.monitor_device) +""" +Support for Synology NAS Sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.synologydsm/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, + CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, EVENT_HOMEASSISTANT_START) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +import voluptuous as vol + +REQUIREMENTS = ['python-synology==0.1.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DISKS = 'disks' +CONF_VOLUMES = 'volumes' +DEFAULT_NAME = 'Synology DSM' +DEFAULT_PORT = 5000 + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +_UTILISATION_MON_COND = { + 'cpu_other_load': ['CPU Load (Other)', '%', 'mdi:chip'], + 'cpu_user_load': ['CPU Load (User)', '%', 'mdi:chip'], + 'cpu_system_load': ['CPU Load (System)', '%', 'mdi:chip'], + 'cpu_total_load': ['CPU Load (Total)', '%', 'mdi:chip'], + 'cpu_1min_load': ['CPU Load (1 min)', '%', 'mdi:chip'], + 'cpu_5min_load': ['CPU Load (5 min)', '%', 'mdi:chip'], + 'cpu_15min_load': ['CPU Load (15 min)', '%', 'mdi:chip'], + 'memory_real_usage': ['Memory Usage (Real)', '%', 'mdi:memory'], + 'memory_size': ['Memory Size', 'Mb', 'mdi:memory'], + 'memory_cached': ['Memory Cached', 'Mb', 'mdi:memory'], + 'memory_available_swap': ['Memory Available (Swap)', 'Mb', 'mdi:memory'], + 'memory_available_real': ['Memory Available (Real)', 'Mb', 'mdi:memory'], + 'memory_total_swap': ['Memory Total (Swap)', 'Mb', 'mdi:memory'], + 'memory_total_real': ['Memory Total (Real)', 'Mb', 'mdi:memory'], + 'network_up': ['Network Up', 'Kbps', 'mdi:upload'], + 'network_down': ['Network Down', 'Kbps', 'mdi:download'], +} +_STORAGE_VOL_MON_COND = { + 'volume_status': ['Status', None, 'mdi:checkbox-marked-circle-outline'], + 'volume_device_type': ['Type', None, 'mdi:harddisk'], + 'volume_size_total': ['Total Size', None, 'mdi:chart-pie'], + 'volume_size_used': ['Used Space', None, 'mdi:chart-pie'], + 'volume_percentage_used': ['Volume Used', '%', 'mdi:chart-pie'], + 'volume_disk_temp_avg': ['Average Disk Temp', None, 'mdi:thermometer'], + 'volume_disk_temp_max': ['Maximum Disk Temp', None, 'mdi:thermometer'], +} +_STORAGE_DSK_MON_COND = { + 'disk_name': ['Name', None, 'mdi:harddisk'], + 'disk_device': ['Device', None, 'mdi:dots-horizontal'], + 'disk_smart_status': ['Status (Smart)', None, + 'mdi:checkbox-marked-circle-outline'], + 'disk_status': ['Status', None, 'mdi:checkbox-marked-circle-outline'], + 'disk_exceed_bad_sector_thr': ['Exceeded Max Bad Sectors', None, + 'mdi:test-tube'], + 'disk_below_remain_life_thr': ['Below Min Remaining Life', None, + 'mdi:test-tube'], + 'disk_temp': ['Temperature', None, 'mdi:thermometer'], +} + +_MONITORED_CONDITIONS = list(_UTILISATION_MON_COND.keys()) + \ + list(_STORAGE_VOL_MON_COND.keys()) + \ + list(_STORAGE_DSK_MON_COND.keys()) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]), + vol.Optional(CONF_DISKS, default=None): cv.ensure_list, + vol.Optional(CONF_VOLUMES, default=None): cv.ensure_list, +}) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Set up the Synology NAS Sensor.""" + # pylint: disable=too-many-locals + def run_setup(event): + """Wait until HASS is fully initialized before creating. + + Delay the setup until Home Assistant is fully initialized. + This allows any entities to be created already + """ + # Setup API + api = SynoApi(config.get(CONF_HOST), config.get(CONF_PORT), + config.get(CONF_USERNAME), config.get(CONF_PASSWORD), + hass.config.units.temperature_unit) + + sensors = [SynoNasUtilSensor(api, variable, + _UTILISATION_MON_COND[variable]) + for variable in config[CONF_MONITORED_CONDITIONS] + if variable in _UTILISATION_MON_COND] + + # Handle all Volumes + volumes = config['volumes'] + if volumes is None: + volumes = api.storage.volumes + + for volume in volumes: + sensors += [SynoNasStorageSensor(api, variable, + _STORAGE_VOL_MON_COND[variable], + volume) + for variable in config[CONF_MONITORED_CONDITIONS] + if variable in _STORAGE_VOL_MON_COND] + + # Handle all Disks + disks = config['disks'] + if disks is None: + disks = api.storage.disks + + for disk in disks: + sensors += [SynoNasStorageSensor(api, variable, + _STORAGE_DSK_MON_COND[variable], + disk) + for variable in config[CONF_MONITORED_CONDITIONS] + if variable in _STORAGE_DSK_MON_COND] + + add_devices_callback(sensors) + + # Wait until start event is sent to load this component. + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) + + +class SynoApi(): + """Class to interface with API.""" + + # pylint: disable=too-many-arguments, bare-except + def __init__(self, host, port, username, password, temp_unit): + """Initialize the API wrapper class.""" + from SynologyDSM import SynologyDSM + self.temp_unit = temp_unit + + try: + self._api = SynologyDSM(host, + port, + username, + password) + except: + _LOGGER.error("Error setting up Synology DSM") + + # Will be updated when `update` gets called. + self.utilisation = self._api.utilisation + self.storage = self._api.storage + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update function for updating api information.""" + self._api.update() + + +class SynoNasSensor(Entity): + """Representation of a Synology Nas Sensor.""" + + def __init__(self, api, variable, variableInfo, monitor_device=None): + """Initialize the sensor.""" + self.var_id = variable + self.var_name = variableInfo[0] + self.var_units = variableInfo[1] + self.var_icon = variableInfo[2] + self.monitor_device = monitor_device + self._api = api + + @property + def name(self): + """Return the name of the sensor, if any.""" + if self.monitor_device is not None: + return "{} ({})".format(self.var_name, self.monitor_device) + else: + return self.var_name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.var_icon + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if self.var_id in ['volume_disk_temp_avg', 'volume_disk_temp_max', + 'disk_temp']: + return self._api.temp_unit + else: + return self.var_units + + def update(self): + """Get the latest data for the states.""" + if self._api is not None: + self._api.update() + + +class SynoNasUtilSensor(SynoNasSensor): + """Representation a Synology Utilisation Sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + network_sensors = ['network_up', 'network_down'] + memory_sensors = ['memory_size', 'memory_cached', + 'memory_available_swap', 'memory_available_real', + 'memory_total_swap', 'memory_total_real'] + + if self.var_id in network_sensors or self.var_id in memory_sensors: + attr = getattr(self._api.utilisation, self.var_id)(False) + + if self.var_id in network_sensors: + return round(attr / 1024.0, 1) + elif self.var_id in memory_sensors: + return round(attr / 1024.0 / 1024.0, 1) + else: + return getattr(self._api.utilisation, self.var_id) + + +class SynoNasStorageSensor(SynoNasSensor): + """Representation a Synology Utilisation Sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + temp_sensors = ['volume_disk_temp_avg', 'volume_disk_temp_max', + 'disk_temp'] + + if self.monitor_device is not None: + if self.var_id in temp_sensors: + attr = getattr(self._api.storage, + self.var_id)(self.monitor_device) + + if self._api.temp_unit == TEMP_CELSIUS: + return attr + else: + return round(attr * 1.8 + 32.0, 1) + else: + return getattr(self._api.storage, + self.var_id)(self.monitor_device) From 9e153119ef618022860935ae076c6245514b2c65 Mon Sep 17 00:00:00 2001 From: thecynic Date: Sat, 20 May 2017 04:27:35 -0700 Subject: [PATCH 013/105] Point pylutron to pypi (#7664) --- homeassistant/components/lutron.py | 3 +-- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lutron.py b/homeassistant/components/lutron.py index ed3134c275a..af0175bbbf4 100644 --- a/homeassistant/components/lutron.py +++ b/homeassistant/components/lutron.py @@ -10,8 +10,7 @@ import logging from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['https://github.com/thecynic/pylutron/archive/v0.1.0.zip#' - 'pylutron==0.1.0'] +REQUIREMENTS = ['pylutron==0.1.0'] DOMAIN = 'lutron' diff --git a/requirements_all.txt b/requirements_all.txt index aab5de87f90..7e4f6388149 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -306,9 +306,6 @@ https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4 # homeassistant.components.light.osramlightify https://github.com/tfriedel/python-lightify/archive/1bb1db0e7bd5b14304d7bb267e2398cd5160df46.zip#lightify==1.0.5 -# homeassistant.components.lutron -https://github.com/thecynic/pylutron/archive/v0.1.0.zip#pylutron==0.1.0 - # homeassistant.components.tado https://github.com/wmalgadey/PyTado/archive/0.1.10.zip#PyTado==0.1.10 @@ -587,6 +584,9 @@ pyloopenergy==0.0.17 # homeassistant.components.lutron_caseta pylutron-caseta==0.2.6 +# homeassistant.components.lutron +pylutron==0.1.0 + # homeassistant.components.notify.mailgun pymailgunner==1.4 From f637a070162d95b0264b9b1ae9472775b6ebf888 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 20 May 2017 08:07:32 -0700 Subject: [PATCH 014/105] 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 zcmV(vKp?|*vt z75?dqEk%=^dluUkX?zp^rIuO7&Fm!=DzYXA^;YHEj;2s2KJeHz+Nm9hPMX-r0AQ+m zEgGJ*a+H$g?;jtY& z)JD@bp=&)&ae>UvZ&^(;dU^2o(|_*|zJ2#x;o}9`i)t+aGxI^GYv`uMu@2E;vF_)T zH%8IdgWniWf4lkXBlnk{y>Ynj-8c75VfF1JdUJsN4H%dAMc4PYuUO=sIpPfM@@plC zd8Mel9|HaW1w0s<{3y|r16c%#->dy8r)`uR8hTkTdxA7fB5%=JQvp3iQmm3 zEK++w=3$$g{sGouiy>yEmVdA4u8=z<@`l_dczPF-of=q>Y#yTjyE~N2{EKcbDFvwV zi{A2svGCce8fwgN(_36xugxBE>x6;-oG`=K!HUM*BhOo8F`Q9M}k zFZM9ND33F9>w!SY7i-r@5W?~{kb8_D6TPbW`8Wgy#v~C;-5N- zv>4!}8|$@-3VdK_Y1MBG%bMSpyHXGWO0DhBx1C$Dmh{bgOx!S8DI8smn#1~_J37(`Q- zat0X_R#?mq9+uvt%vT(xY{wo!-C=NitGqICy3MN(T?33=Zll$crbmwcyb!q+-4aua z%ld5c?8}Ubk!g&$S_9L|Q>!&KSZj_Wlh?(JV_?6n*szaLn|~G4C}U$IWmr_D6)xK; z*B4b=7E`70HW3#H>sYY4RvDEukf^9HcIh-k{{x-CESLiIKF^ z=sf6hPHHN1MMU5%PWnG`z7KU=zt^?+jZMrqB-%A$s?+pfXes#F#Q?__iQGCoen>t| zK;K-ZyR2>^%C5CXuObmN_-PC4T<$O?B}^x5d{ z;?7prIH$u-`BAKV$DsD*2^~(G4gQWWq)fw}&7l}q4|d&eR99}|$0rk4 zkzox&T7SnzM$*1kSu+7n<1}3r6-$w=iCBc- zAJE&{-bUNF$O=(=CRUJeT4jjVNu@*bpy(^Mtv_zuu0j0vI-8{K_KJI*yDc|c&$XA1 zi{Qw~NwLBERjLNGO&>!=eg zmiJ{g$=IQh5^i{7%x{xK2*YdTJ`lB9yJ{I%H(6HY#Y>rkat9TrhP=wQVG9)dl#xom zSw{jk0{1cmb|!t+?}1c;*O7#T7XCEi9FLJcZ=yD7M@Ipyb#5b2ocSyJnJ#4kXw<|8j#5KsagYI`pX#p zVO2pHQClQG!3Su31uwE`f!DEOImsx&XZaN8T`vr%3=mjPW|rGmJah03UpPLr)!rU5 zF3O{I7MI~nxkpoIY;wK!IGyUoyVne~_WB7p0oZBJuuLa{ zxK7oVmL8dql|w=*$J;B)&9}}4(`yx>EQUQ&WU-|bS!^doQW(O1K@vaTD@ck!MXA}} zNM3hRwgcyV>XF`}wO5g29ZUX0Qz^R)_0Cq+YRkj9XbzxPAVPKG*Xv+?xk+;l*ng9! zD(d&nc!K0!iY(&gzIK;&Rl8a>KWQhC2IR;CL=OaA@WqG;qW8&9mB)oOIjAO5m?GT1)OtK&`yeR~PB}!tbC@<%O3|L~ZC#7?+&IM32+PH-9glzd3kw_{|TeZ=V14-SZ>Uf#aJ3sB%5Kym`Y% zCi`;4aIiGlMV=Ho?1^V6!oOQ7>zHG~u_X3~RmsXz`ZjUtMr21~nFL?jaxjN-7SkX5~zA}yXf;zYHd#0A&r(1D3QnGM#rhcT9PvC+J3ny`m>Tl=2Lszgh1 zw!!=nt2~+b%g_R5N?o2-vB&v3^QwTHMQ5qlbDgD)TjMg?*yDggB7fp0Bkcy)cAqzO zDvgU6KYfLF3d7rV?7zXg2IxI`)R4#F({hJg$vp;d3~@1cJm&gMSVerSF7yZc!8*2{PE6+b>$kw_dchH3Mxs2141cf^%)@z9;o7(MAbEIh-2f;-2V6S_XoFRg4`3mSSsTdUYfIoo z{+^6u{QCojPHJUPB|65AgX_y2<0;2HX9`9)cCTQgQN}bwNg`lULL(v*r+pMn30$`b58{yB2STYZVr zEh$XwEt=6kd8qNWFy#6^D56;A)Ws}*p3xAm8+(T$KKXGu@#PBWLqY>dhA8FsA%B0P zUc_~q^=%NN$yg|DF@;$b*AtPgPHv<~3+Kq_FH%Xb=6@7sWCW*6eRBkK1U=jj7K<>z zx)??~64~Y!lCj_0tH@`hwAxr;(mB*AJ}Z#&o}roH6%cR4dQS9O>IP~pf_nFTiRoU= zF&j#R#>EH`B&TyB&WIKzajLnjR*Sg^3&p5KDQ?h@2sm1L{fuKr(UH#z*0)IJ^F}6k zbzWmAVGbZOc)X+w{u}f2590ZUle%ytENRKVN9nOU?tkd+6ZefEzMW;u7FmlOJXxCt zqx``t(e2Ih{wZ%eDxWXmCzAbYL7*QM z^MI3paUDTkj{x@d`J%c8xs7y&&dadwGn9rEx;Sp#6bANpGV`@2oCG{AVt)+$1`(mV zyG5kcUnJIMXHB>OhC)5GlEijn@75G&mtyWu7f;61dc>oCTP>fC6XWTL{QXVN*&s%< zMFF7a$D}ptew&}SMrC|dwf~0s98_g`G6H_bHimT~;w&&ODdJPoO2lHclFvU-TBv}( z1r!5WCsik&`oBm*zbSPEoC2z#%W@o6w3HvierIeW&y&0YU#1> zvue? z071)Jq#9{38a;yl&M*=`pQR=8LyxQqkAkO9+8pGotj$4S!Y%MLxqqyh_ErYlPR=o8 z`}$Yn(C0h{^&m;ai>JT6mAfIq4dRxRcM;1Af1S%>KF`ZF%#S*MGA};RyzEwBnBs-` zF+fE&N~FO^PG~4UJ$>>>_V(lvGz$M36TL?=v?OdB!angDB|GF$0R1^qi231QfBy!g z~B^j9BW!^U#iHh$?auT7e69%v^c;VCJAfkMXp75(-7HS z>iisG^|m@_1s4V<;5wE$Q2MJ=u9TkIFc3XXmg+?ttS%&Na+7Z^at?=i!{?{-rK}9M zfBy7URW0(Y6oW&)FIxZ}yb>skmdDi4L-W*Ae1L4>r@Z&p~X+Z8uhE_bV zRxchn*r30^rZhNa0e%w&Cw#w>`D-nXt67^22|?hqk=R!~JU!)}!ZuHhb#Z2HC&))R;#YC1KCBywUoE{!e~wGC3%P=zro9_dE^zrI4*4!+dU+Q0(sB z4>MaeB>b?F@L#uF(WVpR1sriBdpUdD=*WVN_LA3B*j#i5mq~oZ+Gq9x((+6lvLMg> z*#dYGgb1z~a9)xPkgo2iyVr;kU{e7A_TIUfgYNTM5QPr$?D@2r(XsV2O^w=mz}$G) zy>s&(AAg-ayJ65G&SQ7nVz-&WFy0u@4y8KP{KSI|d0_Ur>JF*9|8%emn_lnk3i-^T zC+uqHuM_pr{!M{mxh<+?GJ*5m=vp-reN=#VUPx{5e7QVbLagPgEF#d?cs39ft2 z-B$?oS2ztQib?qIfU6@LqRo2Ak6uJwR>OSoz##;q_^L-0)xX}cSu&z90;CKoD*f$7Uwr_a?BA*gn+2@ z*MG>*x{eJ3e)sdb7|+q-v(zaC@-#I)t+<(Ri!($IsOJP(N0vU}kya@9EzIZHJRcV2 z&t_sSqz`%VAQN+LnwZDinwSd}@<*7H*Z0`dX|~m>{WrF#k2hP?A2i)%2E4%#Fk>~m zsHA3k}~lP&VQFuPK>zwhrozyB;}*Wdzf|};3VJh^8+9i zZ-fjbwEdR>hI)SpT&RKx7V+ZegGzH*7v=l^p>T1aG6X=4e|FIPdu%4myScPL)L`C> zoD{ptk*|omxs!tNU>w-9zHb{63}e+&5~vhZKZN7R+@1n6d&&+UkCz{#Pb74XihtZ# zn6p*eR$HmbJC*uX<@2JUNJ~XYnXb>!x;at$^EzKpn)HcCWS#tB`Eeq$VkhIt|Kme? zIP7#7t*QC&SZZ8OqTAW3ZmOCT?UPxxfS-U7P+1lkQkrY}5^ZQ&m5wv zZg2V5dL0;=X90rX_}EcbpPU2(2ToAPQgLIE`AwN!6*FBzyif&-4|=VtS_t7oZW%|3W5e>|3&|F*OH%w06v`nX z14HR%$T}Nb|I^c|G~|>+@gZQGB!!el8^dz<(@lLAc0S1TC!9hLUcGpE@b<;)SEq;X zzIt(Z`qlIQc=7yA63kX-fO=>7j|Ck2qtD~fAHR%8U&i}U5MxwBU4QNEiY;2|TOC<|K&K&vj#usBmY+F@2JZ)ZyNIN zzr4Svll2yM=&eBMCQr`!n^0~V_5H@?9Pf#%_~#C;XkE0Crjqrm zMV*IY2PoD+T7MJyDzd!X>n$p*bDVXK;M*NpblZ3Cj@(*)rE8o!DqaU9en!dTd729! zG!XtRU(wR^LXE|;3+_FdI3-c=)y|pdMNXmg94}`Osb$=l_rQXnJE|}8Bqwf$f3!Yi z^c{luC6J@9h`qrZPY=;J(=ILW%2nPDn-3R(#oQ3dvw!#rgN?zDnu$Rzz3;ex`~^_1 zcJZ>n3&}(k{p4+v%Lv^(5~6<^lvOEGJG_8#zw!q|6ACJtnauf0H!ZQw%WN5{Tb2ZH zqjY5Fp?9tc;H39}p6%rveV^|*FK)0|=OO^!zehy5yA#3r*cgl@(Jj~r;dx3|ap{9V zC=+5Tz<)C#+>7ST+iy`PL;k4JLDlwUR^fdB^I*B!uX4bq9HVUTl}{p@Kr|@~Ei5|r z42WagtCUw+oN3vU(v^32U2kgReqyN0*&TBHesf;F+ZlXSl0jgW#e96Ym!t#pj>mzS zQn}_MJnf_o%WQ&z)&Qu{rMu5d8RjwvS~e3=4SzaqzyyqqU?L(Vc`#_%OQ0qq7egR+ zTo42HB?_^`ZlK$UqOYvEDQBU(Bbd5cdpDcUVINW@U{%l)nrF1;G@lmoyXG_ZGBT~q zq~|pDT~j$O<|l!lW&ybx!B>i{j@jB2syn--ciYl$dCS7x!{Vwx#~?WRaB)a?+Kr0p zet!berssa9s9_BR#t&*Hq7#wShL3DSAD&Rq|*X{-QZw&If&@TOcpq{T#4feE=NouO|bc^5rKL z3J*4DJpzW#==h4r3koc_0q{m(vzG?L>wo<0ebElJrnmuNE)R$MAa_svZv(nn>YY_w zb-w}l=?FP!mYB5!8DxOZ$Ct(Y2l)TsD$mhU z2_EtT`111i0Qbe01Nh(Y%O6o$f?wdz1)jvz#3O3%kvov^UPGk3;q@O@xLV-b@_DuB*|uK2i^)b->N83l10AS-u#;l(N>Xf%@q`EI+P0FZWT~kE@i7 zCql7}7Ll4r#CYT6L^IL4qd-#w6c{2*E%!7h?JUOPZUz<=5jGRtD7O!_!GAc-A@`vb z^@f(xVv)Dlg@m|2-|ia}=C)P`@dU$gtO@n_-_~K2i?A$gNx2B=~H1EoBaD@D2m3T&p(fPw}7Wt9_~=?hhKTo z>>NnSuvwj5=GnZ1H1rYH@P7)hj>$a-LyYxK9P9?5QY$l`eWadc1kja$=2SM}NjsKQ=m`KolIW zsmM>pKZoAR4V#_841n*Elzl8Iy<7$|TOz&pV@7wMiv=Vrt9#7T5ltNrl~!}~xG~?e z)^jM?>BP3*d=Zc1(PQKnx5an=ZrO;i1n>kIngg~_<#RUdK1qT#yG z{4Fm$s!8ZvmQ>0q&VPK(?T4Z%&WZ&ROmS^bjEQJJZK1w??t?E|8(pf>w|_pNmkry8 z+DWqW77wINZV1~CS>pfi0j5G}nf&HA6G5;Q2lU04zxM2%pAoG)D<*arV#<$0QUStl z>jChCK=KLX$?8PG%f%@;s4XgO*AwK$m?!;Qm@I?AuXTYf_kWwgif*KhKL^LxOA7Qr zEwV}XYWtw5>lmIP?2IT~8VvV? zr@Q4@vz(Y-Ot^Ul_>WiINxG^Pttk;mC?-oNivyw`FMm1ADUOSglDqsV&Mh6Z{`6bz z{ZeF~FADFR?ge-6&3Cc@y&-Yk>$~!GRbL4;rT6>yNCvVNcZ2`pJ3}m=zkvA@TG%Eb z?c;*?h+mfrcI_~0+1CG_3atFTziHD3MRl|hYS9@TW#*jam@N`2*k8P^JGx8d#IwP5 z=Kb1{hN;V|KKeE4jnPwsn@!Gc5_?k;!iJMa+At~9|47v>VqQzWRnhriUHP>nS@YDH-1?3 zIu4^N?P>mzENK`;He0Y?*fw6rw^FQ2_SYC#$`c#vz+U{&L52wUNl{W2Y0PiHAkN-@ zEq7A~F33l+JBcUr1#-7vQlV*k^ z0soVVhLi$&(32pC9S6&7IYL*Dx06JNPXTe0l!q???UTfZbpa=nI*3gHPm_p$?9I44v?=ndXAZd zPv1O0dj6IIsJwpn7GtRF=bzIJInp}4&CXs3lgo)Z5G|ppAXW`OgCF#Uyb9pO^OF^d zPXX7HWr{*-%C3b;E71xr2y5paqqh#JK5$X~R5~U8{DX`p zo7h~G;JNAVFz0zb$28tvydq8{_8f>VcsE43=siB89e(8gLGG_vND-W7M=AcG<|G*V zGgF3bD$^Kgv&X0v+_XTMqWKn`Y@Y!enwRseY%xEqO8eX8?fiiH`ID)PBPH)mE~gta zDb~zJ%tbos(UZtFHhSp#vW?>11&Y*()6PJs*gEZK zr{+(N{KB_9zrgSkA_jsEexOhT@7uyGejH-3o~%YeG?61vF<^Y(+$^Od5ugYQ+W_u>PBmo@^v8JCE|)FhyI{& z@f=k~6-eZYaI_ucs?_a0{h4JocoSOspWe*R)z0v$`KeRu#CB)A4CF;b15IIX(xoE8 z(3GWr9s3TXaa&XMra~MI08?p?@&^tiueLn~tD-xc_=7@?8`qlXgZV-hAf`fnr6>GE zFacV_71a}dGA2(Sjb{#74ro*^Za(8E<@~@O_;QH&zz^o7+t1uvw*Ye<8vei+S3rPB zx}mq~i9Tf?%}?@)uE0K$K=NcC-Y))Sm&*2k5&KWLOSxqnapY3iW5=`@sJF^qU>N}r za=xIOminJHmxrXZ4%RUM?@dp&@rG(9c6lAetMhYFjP?4xLAxckZGBHuqFLuPBbggI zTp^A%MVh$py>%;*ql}Stfi5&VwhdZfwHP5^yAgSPCGI#kT|OJh4{V`Vwn4ywy6r80 zJhICEowq3x`*QSnX79`J-xC2vD4qCTJRm~_)H@uYyuD?K!V<29V<|P@=tH`1$f9s>46=^gf6?dHf@` zhn0K$p6E1_{RA9S)1?wUW);P2;cc9p2PN+wsV4L%=fmBd^4?VMruJhJNQI8C0U0Wp zPfVC4Y`JgVzWg?|{R%nmt?lxKZ49Y^cKN#1!BTY}YpGVFecTbsRk<{$$jnoJ{&PG& z;Snd&|4UtM#1M^b4d-)Lk47d9XzA|mH~<HJP8cHOa| ze!{O8cHyV|_JZD`4?-IT|5*l6!a!$ZiCm=67_m0m9s2FHy_neBoK5~a4QlK%ZpUs& zmUWqlv@{s%iZTZ{!X1Cz7&MTG!ucZqIDppZTkup~r@d7|*&kH!H!BQ(<`+82Q8Ffe zaXrG&J0_28#G4Uv;(p}B@|$P%dnKQXPth9PVDlD)7c*GS=NNzn01iyh=D28vNTo;2 zvZrW{W82Uawi?bdEAxdpmf_fIxJ`P7wptWGw!PBXO|r&se8?~}eR(tx%C zEvcyP6t(_+fSl0Xs}}cusjp8;{~ca6mpN@Nt83I4^|QwWk^U%u`QBCX-~5*`Zw5i4 ze)6aS-A-=OW-&C2dR=lg?{=87nCMWAp(V-`y2I(0biy#A!LNiSp)$ zQxd>~`7%<>oFPY;>lziJ`wnFmsoNW3efej)gCPR;)AUDqG8<~fWt^KiFEIHmq<5I;B0hce7S6DRm;1( z&Ptb41*@EyCGrL7)>Vr%#n~&}@gZ?OD~#*EyPM$vRKY00oDcr8fFHV`oMIajeq>WNp$JsOR%KoCvoSnupjqpUZhN8gw6x)W?K;j4* zs%3+WuL52bNhGQfiKhTf_5aD{(pfw2T1&aDPQ3zeno> zDacuRC`)!D(058G`;2(!E5WSi<%%Um2q&&R|{t~|EIHEa!iW_ycYS87SeIcb422({;ON%ho==Aim zy;{)hHK?lqaTg8rK}kRNPZ&cNX$cDe%UNof>o8klyrj<_#n1-qCU+Z-fK6vGg^vNk zQ=$yOhmyrw-S92rdA!ixKE97J5?rNzBarIH^cJM~;}Z6n1_gd3gn@Y=EXD5~AXg=S zf<2FBgt>fmNG&txjW!hPJ`(K=RB z6z!d6{hNIMkAM8*BWf&&9mx8eqe?ilyXQWxd*gUqJ-EBLBE5s<7YXko1~?`^5xbsu zlco*pvc%J`ktD(PO>O}9uEHk zBltUNDuE>gfB>(ihkIdCYmv7cnB%`rM3^B(v5{DJcmZ|$*O-^VJ=iA(v}17$NVunb zq<+u(#F7Y-dI2OV2|H<@O!X%5 z@Hy(bwFDKp{Qfni#5@-N0*H=(*|Lg@k|jAJG3x8??q6eyC$)pzjo_+{ZpekJNSm>? zQ!<$72Bj&O)*&9hw{gnq+5V>2&%c_Do)e!)T@z3Z-jQ>bTt%gI*x9pq&Dm=M=ZHhp?E@?OA#gccoL!ZWCfgaPJ# zCPJ3IdYaCGx|y5vEK2Ze2GgbS)6c#Alj$lJmqBeB)y#ILeMEJ%W!xSVbL>+Sf^0>%9K4!LOp4xuQ6PMj5`Oo z1=Ixo1uAl`e+gEd7^PC~@tg<;$`bJnT{=|V-N@_nXi))XK@hZjqLXM!hdV(7CW1u8 za55N-!l7g+SV%MUIu&!>YB1$-CBO;vh<>B6QpNb2{*g<6{?|y?FQY(GI;8f<%@igd z4KKJK=mnbqCE%!)!LFii=U5%O)-yXZGm6f%&`cTsCgBfsIfecJ zwTU9BZ_o{#+MxLX7$@=pQJOKb+2|-YoM2Rjys1f5F%4^0(~xd0K4Jr9P3;0s@W&(m zSev5+MxqvfZ2%RBwP0MNQcE-`P>+Su8c0G`U5ra|Thj^f74VxW6_8JHWyT^D8&NVv zdiK}08e|~jh00DiHlZs1UXXO++jPoU7A?dBat!!*3iwV8{yFRTQUW!Fv5BpXJ4Whn%zzojT@6Oxy{}0&F31W+IsZ zb4{?h4*8j;XguqZ%meew8XvF}wXEvU6Kz)b%SG@-8Jkesk1xq&xHW{j}iK$ zAIib=<$jtF0%?j8fASGlz0vcG zm#H;>w*roBenI{=cXt__;SS6iIMR8f@dc`eByxU8=XZA(aFClc6pnKMf;`IOYx(1q zu@UEDT@ZZBSd21V8A~vWQMP@ZV=#DYDDpKJ?g~3hQhNQpqeFrIrC8whl z*nl&|Nyl#`;GY?Q_ptv*j5gn;3plnX*T527@9thZBMVj=uwZ zZSZJK!nf&lY+^xrot}nAPy)t$t)ZZQBH}<>%&4p714_ybf6EQWAK~daAUldOFq3P4 z!a|n30ij@$H}PBKK`a!Mwtx?C1j68-Ba`+D|KBb1EIkij#b?kP>L}*%2VlPp>4Ews zt>^CUGeAiAa}9rP7-k2lS$1;3We&(XVu>Bk;tx^$7MV+T1R#ujz=<-`15 zX{OKOXQ;N(g8e&y&-ZCQ`9A%=1G-6n6uwKpk3`MMGqb06@v|PK^gDB**Eoh9*wfYY zPoq^yKK)3U4q@b%p{phEY$WwIT*j+-7R5r(Ad6a$NY#YaDaT7%xD8}5-6j$Op!;4~ zkvyMB(;?#4MU%gP)J<9dgMre5tpaJyP8$aOD$*KBXG|KdZIt;yQbBNn{ZmGN6R5HS z(jG7ib!Ua5NwiFBXJ_eV3}pwlumcEL8@)%<@I5ditN44G0I-HNtu<;6pi?+Z!q;eW z0#YS#yM`@jjB3T;WsQ9_oV#V2JC@%N?T?@OD%H>r2LL@mr2 zbN8k1jOGaYYU)QUV~C4t!dw>XK<;oll>Epijqv>9!$$wSyE8R~Zyi4GJZv$l*BT%X z?=2bUndYd!#?BP-|j3a!t!)^@4ck)c&4G{>~|8Hm!oQ8Sm(SW)Z+bkL^3d zfR2u%z~2T18RKy?501nu4ttDq_y?*N4p7nX4*&T!9mmhp{rF}27|7x0@mFbZig%W> zS&OQS(;)s^`UqWG?(&b}G->WG?#jF6-DlBVy}Z1uuA957a&}j?*LOvkAI~mN?tZMQ zyZ@@{`J*C6(_{Qi>N_caZ?4X&1*&@Jhx-BV_toeHIrW8X;gb2<>xBSq_IH=NXR&<0 zBY$g2W-7;)s1D;7M&x>tx_c)??m-tsJ%q*85D3ZP5g86E`-%6%c>n9MIB`9 zJTl1OzkwQTw2uFrnzz5Tw}NeE#$b(4N)^yns&OhdI#;8-TwMWw^l;Cc4`??48Hl|~ zR<)!FLhM8n5+)%SUDrh`zD2P})6QnA@7Vw31_e-(ODZ@IA%CqL z2jb7V2U~SFN$~Wb zQJkKp7`gSIp3RyY@OhJ;=fG6VB#@BK4q$=G-?Wq9oELwse5uVwABjq8@%7^PmTa0Q zTgf&rzfypZAv$ z-p;^srjLItAQCYx|6!#YF}-)9+ZV!VUb0R9Yz4_pT11I{X3v76+q>%@C;DBGtP9o(#lT>CD>(hJwd3sy(-GT zk%Q~!V>n;vSAf35!CFZkX!siO4bL>tNxN$bH&TYZ7s13ts+hbvk|GX-7x++K$>J*r z5}JQF9SqpF4v&D7&iioPMfLoXQC%Z?u95T^<0hHEImkiQW@5l;_mwW)kI8NwA4Y7& zF8A>4@h$amig#*~oTEl;Mhk7`kWCLcI8N_SOtP~P#_ld5nO*zKl~4gMzvGL1u9Kjp-=g5fIj^Q@xWy% z+oHWWh5d@F(pDH@nIedRWZq(swudbbwKjM$SF0nN-(qq48vPnZBilQt-`rI3@TT5Y zvZ1z;&D8zPWWcdOOLZ=$9efR}+8~G>@|j8UX< z%4BENhx}(on)=}@eSpJ|4QBknjb#sS>?@p|Z0w95?#01?j-!DD+90ebEp)bskn{v+ zHG_qEHO@8e#0I$DJbc>SnrCUOV;ih{M`I#pm8VS8lRS@!;Y!LpzO1ftl<-hQZxdFq z{j(LnNGK_0r(7D%JFAkvs3cANR9t_FUq1r7B9cheN)URFfp)4q8Z{N(v5kxW<}HHX z^=WH78OJi_xJ0E>s()^?pNz(~;3>`UPTEOyQp#(e)(Jd900Sky>R}VzgZx&O2gymF z)^ajIH{TXXmh~0NzjZ#isaAvQ4C7T)1J=JygJ{dHStGeUgTS>agWA-oreuFWSt=0s z)%;I@z|=Znp#Bi)D<@)s7HKi{6CZEBXLUaCCfsDI_nPHD|< zRaj`mq2SpHJuLX5xLcCb47yl}DJ9iXM9IB5EO1604eYp*? zZ&Fo#l3>p(5WnI3S%zILwsl^SA?tUC{&NrK_EQuLqArRka7<(kNhV^fB$qr`!Ek`h zD7k?jK3Q^=W<}AQG(6rf)ySGkQ(=hXKfcs3yh7r-ZyDn(dK`b1$~3 z!`pusR=}U64c^oP27akI`vOqaVd$GQ`xJ)$KzK3yy!`xP3x0n2Y5aVq`FV!g1K9aY zL_qjI{{4S4|K5LCBzyMX&%g7YNsZqI>CB8gOY_uu-ja7e5O{h&>&~Gn6z49^wO1B z0;z69i^WWGx4?wR-O?vBc;XG+21SE$oPe3GXnQov3}0h-qeKQ@LU)qRH!F4$?3i~= zKG1`2kViYia~rv~dVmW00nFe-J_;VNg?(q5Eu~QS2%p5$FQABH?9-tkHLXo>KUieZ zK?V%$^<{t74jPPx3Glhh2Y@s)bk+hWRp*1OGypiLSBrc!c+n1uX0WWF5S-u$)MlB_ zfEOB2W`-rclRW@qO|3Qn3{m}c31V0#i-@k{!8f=X2hv_LxGq4blHnhz358WqxWJL! z#PH9YT^q3gBie}BHNApp1G=p9^MpbMG|8h!xbS~NWttfk)uVYeYaZ!|J`#g_6vRvc zFhcDjE8ipZzRfehEE;UdfW%EWIvFix0~y zjc9`2a1(-|p*u&fqxa1O(5;5+k0saw&QQ!nL z_6#t>F4bIM$c z&0>^c^8d-CP_V;W5JE*Y3|i%`6EdBu|0H&!st{oi3=OfE9eZRpa*OS2-^lXAnr z6d9cCB}P((8D>1MVIETHpz}gxXH9>#SfTGOG(Mil7@9v0S@PMpQu95J5)GLciPV1_=(;_G z6|WW;7y!`g0@VRQU=|(F4AslFsupKi{q#vkQ!~KdDd}k_7Ugh}pSJ;=9>X?UrU5!D zzs0j~j=@7awK<09Rfb4~StPMTO$7!r>w*j%Y7OO)9<3FMR2~9)PXb{~1Kiccc>o6< z#zW<;=*B;3ft< zY_bn|Fc5G!q6Y~^{G{QcK?yxRHGyNT^MfO;cFEi?6l|5DYqcf5GEF8R_15NdxWBfZ zhFwM{S928PsGbYHRBLR>z;bZmV&lu_!&xc$DvG_(svat%5Wxm#;gGVLlaH(%-$ zX&cSUi!xle^nphuTijV9qZ5oVfr&LECuu-KVL0*bYGDK*8o_SGJb>04k+TddkxuDg zS%d*FJ3$UV`BVfQvE5q@z)WvW^%!0yip(s1)rbRB4TJ*R$!4v^I z2X&p@jEaW-atKuXmSRTwb!0(sKlXsT15Y>{H5NosLY5aDK$Taw18`j@Mu^Ab$l4^{ zwU?Jzyu-UCap3|ZsOUxz?t2*cTHS$cdog#8g2FP1g6}-PVL&-Y_UxlYm)9YMQlfBf7SeE zbd@(vc7Xw8&3&q3h2!aAZ8LqVRID+M=v`YZMxqAY1)@WzwL^o8xa}PQha$HdbMoY@ z^YI6<{gMDt-x|5tG#5(N)GMf#4ga}9RSNx^2H_R(e(df%;LHe7z~6>foV_y*ZqXk) zU$2qxaogFl<;Q>gU4P3*@PA+_lNqWJ1h32pCX+3y7=P80czNFBAV+4!ZtAe5$cjq70qMT?S8+x+cW#qrj~EV(!b_ zwV7Gn(1pQCHu1phCLS1|$1z-YcuamLFy8&-WlB3Ec|oN@3B7n=w8#(9Mt)kuS0n0u zeIBWj#trP<=~^kp`pC4&{eLHu(5fJRep_THq;xpzbUewP$Sg#YjH55M$Jt4Tx7CS* z;OoTFZgug~I!T!GJXV!VAFq}#>o!rBXRTz|PFd!-I++-7#c+K+N7J(krTs7it~My< z7;hmn=0h>`C}#w6x;gupnIMxFQbUUsC&m(ZDSc@vasn=AX_$Lzt#{ zR8O<%eu9@~;|@k9_5h_>8pdO8Z^pH*sATS6bZF#^*)k`Y#e;Ln|uY1QViG>0HkhEc< delta 16106 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 From adde9e62314507177273216a2ec6dfbe4a7aa388 Mon Sep 17 00:00:00 2001 From: Barry Williams Date: Sat, 20 May 2017 16:43:35 +0100 Subject: [PATCH 015/105] Upgrade Openhome library (#7671) * Added support for openhome devices using transport service * Style cleanup --- homeassistant/components/media_player/openhome.py | 10 +++++----- requirements_all.txt | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/media_player/openhome.py b/homeassistant/components/media_player/openhome.py index 59d6a8c169f..25d6390bf08 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF) -REQUIREMENTS = ['openhomedevice==0.2.1'] +REQUIREMENTS = ['openhomedevice==0.4.0'] SUPPORT_OPENHOME = SUPPORT_SELECT_SOURCE | \ SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ @@ -92,7 +92,7 @@ class OpenhomeDevice(MediaPlayerDevice): if self._source["type"] == "Radio": self._supported_features |= SUPPORT_STOP | SUPPORT_PLAY - if self._source["type"] == "Playlist": + if self._source["type"] in ("Playlist", "Cloud"): self._supported_features |= SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PLAY @@ -173,17 +173,17 @@ class OpenhomeDevice(MediaPlayerDevice): @property def media_image_url(self): """Image url of current playing media.""" - return self._track_information["albumArt"] + return self._track_information["albumArtwork"] @property def media_artist(self): """Artist of current playing media, music track only.""" - return self._track_information["artist"] + return self._track_information["artist"][0] @property def media_album_name(self): """Album name of current playing media, music track only.""" - return self._track_information["album"] + return self._track_information["albumTitle"] @property def media_title(self): diff --git a/requirements_all.txt b/requirements_all.txt index 7e4f6388149..26e25d16480 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -406,7 +406,7 @@ onkyo-eiscp==1.1 openevsewifi==0.4 # homeassistant.components.media_player.openhome -openhomedevice==0.2.1 +openhomedevice==0.4.0 # homeassistant.components.switch.orvibo orvibo==1.1.1 From 81f08265501ffa08d59bc437d6f78cf63088eccd Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 20 May 2017 21:18:59 +0200 Subject: [PATCH 016/105] Ignore attribute changes in automation trigger from/to (#7651) * Ignore attribute changes in automation trigger from/to * Quote names in deprecation warnings This makes it somewhat easier to read if the suggestion happens to be named "to". * Add test with same state, new attribute value --- homeassistant/components/automation/state.py | 12 +++++++----- homeassistant/helpers/deprecation.py | 6 +++--- tests/components/automation/test_state.py | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 9c12a37f9b8..185d44808c3 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -12,6 +12,7 @@ import homeassistant.util.dt as dt_util from homeassistant.const import MATCH_ALL, CONF_PLATFORM from homeassistant.helpers.event import ( async_track_state_change, async_track_point_in_utc_time) +from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv CONF_ENTITY_ID = 'entity_id' @@ -40,7 +41,7 @@ def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) from_state = config.get(CONF_FROM, MATCH_ALL) - to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL + to_state = get_deprecated(config, CONF_TO, CONF_STATE, MATCH_ALL) time_delta = config.get(CONF_FOR) async_remove_state_for_cancel = None async_remove_state_for_listener = None @@ -75,12 +76,13 @@ def async_trigger(hass, config, action): } }) - if time_delta is None: - call_action() + # Ignore changes to state attributes if from/to is in use + match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) + if not match_all and from_s.last_changed == to_s.last_changed: return - # If only state attributes changed, ignore this event - if from_s.last_changed == to_s.last_changed: + if time_delta is None: + call_action() return @callback diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 88de3a48aa0..ee4176a8937 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -23,8 +23,8 @@ def deprecated_substitute(substitute_name): if not warnings.get(module_name): logger = logging.getLogger(module_name) logger.warning( - "%s is deprecated. Please rename %s to " - "%s in '%s' to ensure future support.", + "'%s' is deprecated. Please rename '%s' to " + "'%s' in '%s' to ensure future support.", substitute_name, substitute_name, func.__name__, inspect.getfile(self.__class__)) warnings[module_name] = True @@ -49,7 +49,7 @@ def get_deprecated(config, new_name, old_name, default=None): module_name = inspect.getmodule(inspect.stack()[1][0]).__name__ logger = logging.getLogger(module_name) logger.warning( - "%s is deprecated. Please rename %s to %s in your " + "'%s' is deprecated. Please rename '%s' to '%s' in your " "configuration file.", old_name, old_name, new_name) return config.get(old_name) return config.get(new_name, default) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index cf715bc5e32..28473511e29 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -110,6 +110,26 @@ class TestAutomationState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) + def test_if_fires_on_attribute_change_with_to_filter(self): + """Test for not firing on attribute change.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'to': 'world' + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 'world', {'test_attribute': 11}) + self.hass.states.set('test.entity', 'world', {'test_attribute': 12}) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_with_state_filter(self): """Test for firing on entity change with state filter.""" assert setup_component(self.hass, automation.DOMAIN, { From 44edf3e1051d4436d93fe2e9b81ad74e74eb2359 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 20 May 2017 22:19:22 +0300 Subject: [PATCH 017/105] Switch pymodbus to pypi (#7677) --- homeassistant/components/modbus.py | 3 +-- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index b7b6193f6c0..0315682bae0 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -16,8 +16,7 @@ from homeassistant.const import ( DOMAIN = 'modbus' -REQUIREMENTS = ['https://github.com/bashwork/pymodbus/archive/' - 'd7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0'] +REQUIREMENTS = ['pymodbus==1.3.0rc1'] # Type of network CONF_BAUDRATE = 'baudrate' diff --git a/requirements_all.txt b/requirements_all.txt index 26e25d16480..6141e3d29de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -270,9 +270,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.modbus -https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 - # homeassistant.components.media_player.spotify https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f86c90aa26b2.zip#spotipy==2.4.4 @@ -593,6 +590,9 @@ pymailgunner==1.4 # homeassistant.components.mochad pymochad==0.1.1 +# homeassistant.components.modbus +pymodbus==1.3.0rc1 + # homeassistant.components.mysensors pymysensors==0.10.0 From 24b7fd369486e379a26d9e1202316717bf160bfa Mon Sep 17 00:00:00 2001 From: tobygray Date: Sun, 21 May 2017 10:11:33 +0100 Subject: [PATCH 018/105] zoneminder: fix incorrect use of logging.exception. (#7675) Prior to this change the zoneminder component was attempting to use logging.exception outside of exception handling code. This would lead to the traceback module throwing an exception when trying to work out the traceback for the exception. This fixes the issue by changing the exception call into a plain error logging call. --- homeassistant/components/zoneminder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 homeassistant/components/zoneminder.py diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder.py old mode 100644 new mode 100755 index 8870b4713e0..86531401774 --- a/homeassistant/components/zoneminder.py +++ b/homeassistant/components/zoneminder.py @@ -109,7 +109,7 @@ def _zm_request(method, api_url, data=None): break else: - _LOGGER.exception("Unable to get API response from ZoneMinder") + _LOGGER.error("Unable to get API response from ZoneMinder") try: return req.json() From 927024714b17c3c1e9879d1dd7d451d33acf37f6 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 21 May 2017 17:33:42 +0300 Subject: [PATCH 019/105] Zwave: Apply refresh_node workaround on 1st instance only (#7579) * Apply refresh_node workaround on 1st instance only * Add another test --- homeassistant/components/zwave/workaround.py | 14 ++++++++++++-- tests/components/switch/test_zwave.py | 4 ++-- tests/components/zwave/test_workaround.py | 11 +++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave/workaround.py b/homeassistant/components/zwave/workaround.py index 8971658d9ec..9d2433b7118 100644 --- a/homeassistant/components/zwave/workaround.py +++ b/homeassistant/components/zwave/workaround.py @@ -24,7 +24,7 @@ SOMFY_ZRTSI = 0x5a52 PHILIO_SLIM_SENSOR_MOTION_MTII = (PHILIO, PHILIO_SENSOR, PHILIO_SLIM_SENSOR, 0) PHILIO_3_IN_1_SENSOR_GEN_4_MOTION_MTII = ( PHILIO, PHILIO_SENSOR, PHILIO_3_IN_1_SENSOR_GEN_4, 0) -PHILIO_PAN07_MTII = (PHILIO, PHILIO_SWITCH, PHILIO_PAN07, 0) +PHILIO_PAN07_MTI_INSTANCE = (PHILIO, PHILIO_SWITCH, PHILIO_PAN07, 1) WENZHOU_SLIM_SENSOR_MOTION_MTII = ( WENZHOU, PHILIO_SENSOR, PHILIO_SLIM_SENSOR, 0) @@ -39,7 +39,11 @@ DEVICE_MAPPINGS_MTII = { PHILIO_SLIM_SENSOR_MOTION_MTII: WORKAROUND_NO_OFF_EVENT, PHILIO_3_IN_1_SENSOR_GEN_4_MOTION_MTII: WORKAROUND_NO_OFF_EVENT, WENZHOU_SLIM_SENSOR_MOTION_MTII: WORKAROUND_NO_OFF_EVENT, - PHILIO_PAN07_MTII: WORKAROUND_REFRESH_NODE_ON_UPDATE, +} + +# List of workarounds by (manufacturer_id, product_type, product_id, instance) +DEVICE_MAPPINGS_MTI_INSTANCE = { + PHILIO_PAN07_MTI_INSTANCE: WORKAROUND_REFRESH_NODE_ON_UPDATE, } SOMFY_ZRTSI_CONTROLLER_MT = (SOMFY, SOMFY_ZRTSI) @@ -91,6 +95,12 @@ def get_device_mapping(value): (manufacturer_id, product_type, product_id, value.index)) if result: return result + + result = DEVICE_MAPPINGS_MTI_INSTANCE.get( + (manufacturer_id, product_type, product_id, value.instance)) + if result: + return result + return DEVICE_MAPPINGS_MT.get((manufacturer_id, product_type)) return None diff --git a/tests/components/switch/test_zwave.py b/tests/components/switch/test_zwave.py index cbdfe5324de..3769eef828b 100644 --- a/tests/components/switch/test_zwave.py +++ b/tests/components/switch/test_zwave.py @@ -4,7 +4,7 @@ from unittest.mock import patch from homeassistant.components.switch import zwave from tests.mock.zwave import ( - MockNode, MockValue, MockEntityValues, value_changed) + MockNode, MockValue, MockEntityValues, value_changed) def test_get_device_detects_switch(mock_openzwave): @@ -61,7 +61,7 @@ def test_switch_refresh_on_update(mock_counter, mock_openzwave): mock_counter.return_value = 10 node = MockNode(manufacturer_id='013c', product_type='0001', product_id='0005') - value = MockValue(data=False, node=node) + value = MockValue(data=False, node=node, instance=1) values = MockEntityValues(primary=value) device = zwave.get_device(node=node, values=values, node_config={}) diff --git a/tests/components/zwave/test_workaround.py b/tests/components/zwave/test_workaround.py index 2ef54ae066b..de901ad4dc1 100644 --- a/tests/components/zwave/test_workaround.py +++ b/tests/components/zwave/test_workaround.py @@ -38,3 +38,14 @@ def test_get_device_mapping_mtii(): product_id='0002') value = MockValue(data=0, node=node, index=0) assert workaround.get_device_mapping(value) == 'trigger_no_off_event' + + +def test_get_device_mapping_mti_instance(): + """Test that device mapping mti_instance is returned.""" + node = MockNode(manufacturer_id='013c', product_type='0001', + product_id='0005') + value = MockValue(data=0, node=node, instance=1) + assert workaround.get_device_mapping(value) == 'refresh_node_on_update' + + value = MockValue(data=0, node=node, instance=2) + assert workaround.get_device_mapping(value) is None From 171086229aa07990cfb13a8095ce0d3fc6813d74 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 21 May 2017 07:41:33 -0700 Subject: [PATCH 020/105] Guard against new and removed state change events (#7687) --- homeassistant/components/automation/state.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 185d44808c3..fbd1570a1e0 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -45,6 +45,7 @@ def async_trigger(hass, config, action): time_delta = config.get(CONF_FOR) async_remove_state_for_cancel = None async_remove_state_for_listener = None + match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) @callback def clear_listener(): @@ -77,8 +78,8 @@ def async_trigger(hass, config, action): }) # Ignore changes to state attributes if from/to is in use - match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) - if not match_all and from_s.last_changed == to_s.last_changed: + if (not match_all and from_s is not None and to_s is not None and + from_s.last_changed == to_s.last_changed): return if time_delta is None: From 4a0d6e73f4dae0a833e3438c85f76d2a8131e61f Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Sun, 21 May 2017 20:15:24 +0200 Subject: [PATCH 021/105] ZWave: Add reset service to meters (#7676) * Add reset service for command_class meters. * Add reset service for command_class meters. * cast index to const.py --- homeassistant/components/zwave/__init__.py | 30 +++++++++++++ homeassistant/components/zwave/const.py | 3 ++ homeassistant/components/zwave/services.yaml | 8 ++++ tests/components/zwave/test_init.py | 46 ++++++++++++++++++-- 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 79067c0d2ef..4b86c191763 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -95,6 +95,11 @@ REFRESH_ENTITY_SCHEMA = vol.Schema({ vol.Required(ATTR_ENTITY_ID): cv.entity_id, }) +RESET_NODE_METERS_SCHEMA = vol.Schema({ + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), + vol.Optional(const.ATTR_INSTANCE, default=1): vol.Coerce(int) +}) + CHANGE_ASSOCIATION_SCHEMA = vol.Schema({ vol.Required(const.ATTR_ASSOCIATION): cv.string, vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), @@ -499,6 +504,26 @@ def setup(hass, config): node = network.nodes[node_id] node.refresh_info() + def reset_node_meters(service): + """Reset meter counters of a node.""" + node_id = service.data.get(const.ATTR_NODE_ID) + instance = service.data.get(const.ATTR_INSTANCE) + node = network.nodes[node_id] + for value in ( + node.get_values(class_id=const.COMMAND_CLASS_METER) + .values()): + if value.index != const.METER_RESET_INDEX: + continue + if value.instance != instance: + continue + network.manager.pressButton(value.value_id) + network.manager.releaseButton(value.value_id) + _LOGGER.info("Resetting meters on node %s instance %s....", + node_id, instance) + return + _LOGGER.info("Node %s on instance %s does not have resettable " + "meters.", node_id, instance) + def start_zwave(_service_or_event): """Startup Z-Wave network.""" _LOGGER.info("Starting Z-Wave network...") @@ -606,6 +631,11 @@ def setup(hass, config): descriptions[ const.SERVICE_REFRESH_NODE], schema=NODE_SERVICE_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_RESET_NODE_METERS, + reset_node_meters, + descriptions[ + const.SERVICE_RESET_NODE_METERS], + schema=RESET_NODE_METERS_SCHEMA) # Setup autoheal if autoheal: diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index 5b2eb08657d..b2fcf448db5 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -37,6 +37,7 @@ SERVICE_START_NETWORK = "start_network" SERVICE_RENAME_NODE = "rename_node" SERVICE_REFRESH_ENTITY = "refresh_entity" SERVICE_REFRESH_NODE = "refresh_node" +SERVICE_RESET_NODE_METERS = "reset_node_meters" EVENT_SCENE_ACTIVATED = "zwave.scene_activated" EVENT_NODE_EVENT = "zwave.node_event" @@ -329,3 +330,5 @@ DISC_SPECIFIC_DEVICE_CLASS = "specific_device_class" DISC_TYPE = "type" DISC_VALUES = "values" DISC_WRITEONLY = "writeonly" + +METER_RESET_INDEX = 33 diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index feacf8229aa..6f61dec5cc7 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -108,3 +108,11 @@ rename_node: name: description: New Name example: 'kitchen' + +reset_node_meters: + description: Resets the meter counters of a node. + fields: + node_id: + description: Node id of the device to reset meters for. (integer) + instance: + description: (Optional) Instance of association. Defaults to instance 1. diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 17fac86c748..de500a81893 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -144,17 +144,17 @@ def test_setup_platform(hass, mock_openzwave): async_add_devices = MagicMock() result = yield from zwave.async_setup_platform( - hass, None, async_add_devices, None) + hass, None, async_add_devices, None) assert not result assert not async_add_devices.called result = yield from zwave.async_setup_platform( - hass, None, async_add_devices, {const.DISCOVERY_DEVICE: 123}) + hass, None, async_add_devices, {const.DISCOVERY_DEVICE: 123}) assert not result assert not async_add_devices.called result = yield from zwave.async_setup_platform( - hass, None, async_add_devices, {const.DISCOVERY_DEVICE: 456}) + hass, None, async_add_devices, {const.DISCOVERY_DEVICE: 456}) assert result assert async_add_devices.called assert len(async_add_devices.mock_calls) == 1 @@ -1015,6 +1015,46 @@ class TestZWaveServices(unittest.TestCase): assert value.data == 15 + def test_reset_node_meters(self): + """Test zwave reset_node_meters service.""" + value = MockValue( + instance=1, + index=8, + data=99.5, + command_class=const.COMMAND_CLASS_METER, + ) + reset_value = MockValue( + instance=1, + index=33, + command_class=const.COMMAND_CLASS_METER, + ) + node = MockNode(node_id=14) + node.values = {8: value, 33: reset_value} + node.get_values.return_value = node.values + self.zwave_network.nodes = {14: node} + + self.hass.services.call('zwave', 'reset_node_meters', { + const.ATTR_NODE_ID: 14, + const.ATTR_INSTANCE: 2, + }) + self.hass.block_till_done() + + assert not self.zwave_network.manager.pressButton.called + assert not self.zwave_network.manager.releaseButton.called + + self.hass.services.call('zwave', 'reset_node_meters', { + const.ATTR_NODE_ID: 14, + }) + self.hass.block_till_done() + + assert self.zwave_network.manager.pressButton.called + value_id, = self.zwave_network.manager.pressButton.mock_calls.pop(0)[1] + assert value_id == reset_value.value_id + assert self.zwave_network.manager.releaseButton.called + value_id, = ( + self.zwave_network.manager.releaseButton.mock_calls.pop(0)[1]) + assert value_id == reset_value.value_id + def test_add_association(self): """Test zwave change_association service.""" ZWaveGroup = self.mock_openzwave.group.ZWaveGroup From 8c1181f8e333008fcb9b6d42732ee657cefb21a7 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sun, 21 May 2017 20:01:42 -0400 Subject: [PATCH 022/105] Remove defunct INSTALL_OPENZWAVE from Dockerfile (#7697) --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 54f993b01a9..8c4cd0f5440 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,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 From 922303fd4bd6b28361e7d7401da07d9918a7687a Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Mon, 22 May 2017 02:02:22 +0200 Subject: [PATCH 023/105] Fix telegram chats (#7689) * bugfix for Telegram chat_ids - Negative `chat_id`s for groups. - Include `chat_id` in event data. - Handle KeyError when receiving other types of messages, as `new_chat_member` ones, and send them as text. * unused import * fix double quote style, fix boolean expr, change warning msg * mistake * some more fixes - fix if condition for msg bad fields. - return True for a correct but not allowed or not recognized message: if not, the message arrives continuously. - Allow to receive messages from unauthorized users if they come from authorized groups. * support for `edited_message`s - They come as normal messages, except for the 'edited_message' field instead of 'message'. --- homeassistant/components/notify/telegram.py | 3 +- .../components/telegram_bot/__init__.py | 97 +++++++++++-------- 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 1bc2baa632e..fb453263dd8 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -8,7 +8,6 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA, ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) @@ -27,7 +26,7 @@ ATTR_DOCUMENT = 'document' CONF_CHAT_ID = 'chat_id' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_CHAT_ID): cv.positive_int, + vol.Required(CONF_CHAT_ID): vol.Coerce(int), }) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 235217d1942..fdc9d16677c 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -38,6 +38,7 @@ ATTR_COMMAND = 'command' ATTR_USER_ID = 'user_id' ATTR_ARGS = 'args' ATTR_MSG = 'message' +ATTR_EDITED_MSG = 'edited_message' ATTR_CHAT_INSTANCE = 'chat_instance' ATTR_CHAT_ID = 'chat_id' ATTR_MSGID = 'id' @@ -76,7 +77,7 @@ CONFIG_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]), + vol.All(cv.ensure_list, [vol.Coerce(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]) @@ -84,7 +85,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) BASE_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.positive_int]), + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), vol.Optional(ATTR_PARSER): cv.string, vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, @@ -113,19 +114,19 @@ SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend({ 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, + vol.Required(ATTR_CHAT_ID): vol.Coerce(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_CHAT_ID): vol.Coerce(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_CHAT_ID): vol.Coerce(int), vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list, }, extra=vol.ALLOW_EXTRA) SERVICE_ANSWER_CALLBACK_QUERY = 'answer_callback_query' @@ -198,7 +199,7 @@ def async_setup(hass, config): return except Exception: # pylint: disable=broad-except - _LOGGER.exception('Error setting up platform %s', p_type) + _LOGGER.exception("Error setting up platform %s", p_type) return notify_service = TelegramNotificationService( @@ -221,7 +222,7 @@ def async_setup(hass, config): 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) + _LOGGER.debug("NEW telegram_message %s: %s", msgtype, kwargs) if msgtype == SERVICE_SEND_MESSAGE: yield from hass.async_add_job( @@ -300,7 +301,7 @@ class TelegramNotificationService: if isinstance(target, int): if target in self.allowed_chat_ids: return [target] - _LOGGER.warning('BAD TARGET "%s", using default: %s', + _LOGGER.warning("BAD TARGET %s, using default: %s", target, self._default_user) else: try: @@ -308,9 +309,9 @@ class TelegramNotificationService: if int(t) in self.allowed_chat_ids] if len(chat_ids) > 0: return chat_ids - _LOGGER.warning('ALL BAD TARGETS: "%s"', target) + _LOGGER.warning("ALL BAD TARGETS: %s", target) except (ValueError, TypeError): - _LOGGER.warning('BAD TARGET DATA "%s", using default: %s', + _LOGGER.warning("BAD TARGET DATA %s, using default: %s", target, self._default_user) return [self._default_user] @@ -378,10 +379,10 @@ class TelegramNotificationService: 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)', + _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', + _LOGGER.warning("UPDATE LAST MSG??: out_type:%s, out=%s", type(out), out) return out except TelegramError: @@ -393,7 +394,7 @@ class TelegramNotificationService: 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', + _LOGGER.debug("send_message in chat_id %s with params: %s", chat_id, params) self._send_msg(self.bot.sendMessage, "Error sending message", @@ -404,13 +405,13 @@ class TelegramNotificationService: 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', + _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.', + _LOGGER.debug("editing message w/id %s.", message_id or inline_message_id) return self._send_msg(self.bot.editMessageText, "Error editing text message", @@ -432,7 +433,7 @@ class TelegramNotificationService: 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.', + _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", @@ -451,7 +452,7 @@ class TelegramNotificationService: 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.', + _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) @@ -462,7 +463,7 @@ class TelegramNotificationService: 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.', + _LOGGER.debug("send location %s/%s to chat_id %s.", latitude, longitude, chat_id) self._send_msg(self.bot.sendLocation, "Error sending location", @@ -479,36 +480,54 @@ class BaseTelegramBotEntity: 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): + """Return boolean msg_data_is_ok and dict msg_data.""" + if not msg_data: + return False, None + bad_fields = ('text' not in msg_data and + 'data' not in msg_data and + 'chat' not in msg_data) + if bad_fields or 'from' not in msg_data: # Message is not correct. _LOGGER.error("Incoming message does not have required data (%s)", msg_data) - return None + return False, None + if msg_data['from'].get('id') not in self.allowed_chat_ids \ + or msg_data['chat'].get('id') not in self.allowed_chat_ids: + # Origin is not allowed. + _LOGGER.error("Incoming message is not allowed (%s)", msg_data) + return True, None - return { + return True, { ATTR_USER_ID: msg_data['from']['id'], + ATTR_CHAT_ID: msg_data['chat']['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.""" - if ATTR_MSG in data: + if ATTR_MSG in data or ATTR_EDITED_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 data[ATTR_TEXT][0] == '/': - pieces = data[ATTR_TEXT].split(' ') - event_data[ATTR_COMMAND] = pieces[0] - event_data[ATTR_ARGS] = pieces[1:] + if ATTR_MSG in data: + data = data.get(ATTR_MSG) else: - event_data[ATTR_TEXT] = data[ATTR_TEXT] + data = data.get(ATTR_EDITED_MSG) + message_ok, event_data = self._get_message_data(data) + if event_data is None: + return message_ok + + if 'text' in data: + if data['text'][0] == '/': + pieces = data['text'].split(' ') + event_data[ATTR_COMMAND] = pieces[0] + event_data[ATTR_ARGS] = pieces[1:] + else: + event_data[ATTR_TEXT] = data['text'] + event = EVENT_TELEGRAM_TEXT + else: + # Some other thing... + _LOGGER.warning("Message without text data received: %s", data) + event_data[ATTR_TEXT] = str(data) event = EVENT_TELEGRAM_TEXT self.hass.bus.async_fire(event, event_data) @@ -516,9 +535,9 @@ class BaseTelegramBotEntity: elif ATTR_CALLBACK_QUERY in data: event = EVENT_TELEGRAM_CALLBACK data = data.get(ATTR_CALLBACK_QUERY) - event_data = self._get_message_data(data) + message_ok, event_data = self._get_message_data(data) if event_data is None: - return False + return message_ok event_data[ATTR_DATA] = data[ATTR_DATA] event_data[ATTR_MSG] = data[ATTR_MSG] @@ -529,5 +548,5 @@ class BaseTelegramBotEntity: return True else: # Some other thing... - _LOGGER.warning('SOME OTHER THING RECEIVED --> "%s"', data) - return False + _LOGGER.warning("SOME OTHER THING RECEIVED --> %s", data) + return True From b3cb057aac8ac81c1fec66e864bb08f9adbffb07 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Mon, 22 May 2017 02:05:04 +0200 Subject: [PATCH 024/105] Fix playback control of web streams (#7683) Web streams can't be paused and resumed later. That's why volumio stops them instead of pausing them. --- homeassistant/components/media_player/volumio.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/media_player/volumio.py b/homeassistant/components/media_player/volumio.py index 9bf0351d200..ade49b8116e 100755 --- a/homeassistant/components/media_player/volumio.py +++ b/homeassistant/components/media_player/volumio.py @@ -190,6 +190,8 @@ class Volumio(MediaPlayerDevice): def async_media_pause(self): """Send media_pause command to media player.""" + if self._state['trackType'] == 'webradio': + return self.send_volumio_msg('commands', params={'cmd': 'stop'}) return self.send_volumio_msg('commands', params={'cmd': 'pause'}) def async_set_volume_level(self, volume): From bb8de5845a35efe0b54ef5b314ac89f07b7ae50d Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 22 May 2017 02:05:48 +0200 Subject: [PATCH 025/105] Sort entities in default groups by name (#7681) * Sort entities in default groups by name * Cleanups from review --- homeassistant/helpers/entity_component.py | 8 ++++---- tests/helpers/test_entity_component.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 6e69f772d1e..2c1801a6342 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -232,12 +232,12 @@ class EntityComponent(object): if self.group is None and self.group_name is not None: group = get_component('group') self.group = yield from group.Group.async_create_group( - self.hass, self.group_name, self.entities.keys(), - user_defined=False - ) + self.hass, self.group_name, + sorted(self.entities, key=lambda x: self.entities[x].name), + user_defined=False) elif self.group is not None: yield from self.group.async_update_tracked_entity_ids( - self.entities.keys()) + sorted(self.entities, key=lambda x: self.entities[x].name)) def reset(self): """Remove entities and reset the entity component to initial values.""" diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index a76b3a15068..ade8c4ebd8a 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -92,13 +92,14 @@ class TestHelpersEntityComponent(unittest.TestCase): assert group.attributes.get('entity_id') == ('test_domain.hello',) # group extended - component.add_entities([EntityTest(name='hello2')]) + component.add_entities([EntityTest(name='goodbye')]) assert len(self.hass.states.entity_ids()) == 3 group = self.hass.states.get('group.everyone') - assert sorted(group.attributes.get('entity_id')) == \ - ['test_domain.hello', 'test_domain.hello2'] + # Sorted order + assert group.attributes.get('entity_id') == \ + ('test_domain.goodbye', 'test_domain.hello') def test_polling_only_updates_entities_it_should_poll(self): """Test the polling of only updated entities.""" From 99ea1e3f4fa751898182e2847b25ab3a21be6e46 Mon Sep 17 00:00:00 2001 From: LvivEchoes Date: Mon, 22 May 2017 03:18:55 +0300 Subject: [PATCH 026/105] Continue tracking device over dhcp lease table if wireless adapter not installed (#7690) --- .../components/device_tracker/mikrotik.py | 56 +++++++++++++++---- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index f22b297b78d..af543548fbd 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -60,13 +60,20 @@ class MikrotikScanner(DeviceScanner): self.success_init = False self.client = None + self.wireless_exist = None self.success_init = self.connect_to_device() if self.success_init: - _LOGGER.info("Start polling Mikrotik router...") + _LOGGER.info( + "Start polling Mikrotik (%s) router...", + self.host + ) self._update_info() else: - _LOGGER.error("Connection to Mikrotik failed") + _LOGGER.error( + "Connection to Mikrotik (%s) failed", + self.host + ) def connect_to_device(self): """Connect to Mikrotik method.""" @@ -87,6 +94,16 @@ class MikrotikScanner(DeviceScanner): routerboard_info[0].get('model', 'Router'), self.host) self.connected = True + self.wireless_exist = self.client( + cmd='/interface/wireless/getall' + ) + if not self.wireless_exist: + _LOGGER.info( + 'Mikrotik %s: Wireless adapters not found. Try to ' + 'use DHCP lease table as presence tracker source. ' + 'Please decrease lease time as much as possible.', + self.host + ) except (librouteros.exceptions.TrapError, librouteros.exceptions.ConnectionError) as api_error: @@ -108,24 +125,39 @@ class MikrotikScanner(DeviceScanner): def _update_info(self): """Retrieve latest information from the Mikrotik box.""" with self.lock: - _LOGGER.info("Loading wireless device from Mikrotik...") + if self.wireless_exist: + devices_tracker = 'wireless' + else: + devices_tracker = 'ip' - wireless_clients = self.client( - cmd='/interface/wireless/registration-table/getall' + _LOGGER.info( + "Loading %s devices from Mikrotik (%s) ...", + devices_tracker, + self.host ) - device_names = self.client(cmd='/ip/dhcp-server/lease/getall') - if device_names is None or wireless_clients is None: + device_names = self.client(cmd='/ip/dhcp-server/lease/getall') + if self.wireless_exist: + devices = self.client( + cmd='/interface/wireless/registration-table/getall' + ) + else: + devices = device_names + + if device_names is None and devices is None: return False mac_names = {device.get('mac-address'): device.get('host-name') for device in device_names if device.get('mac-address')} - self.last_results = { - device.get('mac-address'): - mac_names.get(device.get('mac-address')) - for device in wireless_clients - } + if self.wireless_exist: + self.last_results = { + device.get('mac-address'): + mac_names.get(device.get('mac-address')) + for device in devices + } + else: + self.last_results = mac_names return True From ca3f07cdef27f00c5104a83eeb9d065ce146ff54 Mon Sep 17 00:00:00 2001 From: tobygray Date: Mon, 22 May 2017 01:26:05 +0100 Subject: [PATCH 027/105] device_tracker.ubus: Handle empty results (#7673) If OpenWRT isn't running the DHCP server then some OpenWRT hardware, such as TP-Link TL-WDR3600 v1, can't determine the host corresponding to an associated wifi client. This change handles that by returning None when the request has no data in the result. --- homeassistant/components/device_tracker/ubus.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) mode change 100644 => 100755 homeassistant/components/device_tracker/ubus.py diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py old mode 100644 new mode 100755 index 31c7d32c4c1..b1d5aa499b5 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -144,7 +144,10 @@ def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params): response = res.json() if rpcmethod == "call": - return response["result"][1] + try: + return response["result"][1] + except IndexError: + return else: return response["result"] From d5642a5faf397f257dd3a78d2faf3b4f30955a01 Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Mon, 22 May 2017 01:54:01 -0400 Subject: [PATCH 028/105] Bump pyEight version (#7701) --- homeassistant/components/eight_sleep.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 22647532d9a..3a2929f4bd1 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.5'] +REQUIREMENTS = ['pyeight==0.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 6141e3d29de..af7e9b5255b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -521,7 +521,7 @@ pydroid-ipcam==0.8 pyebox==0.1.0 # homeassistant.components.eight_sleep -pyeight==0.0.5 +pyeight==0.0.6 # homeassistant.components.media_player.emby pyemby==1.2 From 47355eed4165d80b1e587b8584ff7046aa0ee801 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 22 May 2017 13:56:36 +0200 Subject: [PATCH 029/105] Upgrade python-telegram-bot to 6.0.1 (#7704) --- .../components/telegram_bot/__init__.py | 66 ++++++++++--------- requirements_all.txt | 2 +- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index fdc9d16677c..e55d3a089e9 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -22,47 +22,51 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.setup import async_prepare_setup_platform -DOMAIN = 'telegram_bot' -REQUIREMENTS = ['python-telegram-bot==5.3.1'] +REQUIREMENTS = ['python-telegram-bot==6.0.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_EDITED_MSG = 'edited_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_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_CAPTION = 'caption' +ATTR_CHAT_ID = 'chat_id' +ATTR_CHAT_INSTANCE = 'chat_instance' +ATTR_COMMAND = 'command' +ATTR_DISABLE_NOTIF = 'disable_notification' +ATTR_DISABLE_WEB_PREV = 'disable_web_page_preview' +ATTR_EDITED_MSG = 'edited_message' +ATTR_FILE = 'file' +ATTR_FROM_FIRST = 'from_first' +ATTR_FROM_LAST = 'from_last' ATTR_KEYBOARD = 'keyboard' ATTR_KEYBOARD_INLINE = 'inline_keyboard' -ATTR_URL = 'url' -ATTR_FILE = 'file' -ATTR_CAPTION = 'caption' -ATTR_USERNAME = 'username' +ATTR_MESSAGEID = 'message_id' +ATTR_MSG = 'message' +ATTR_MSGID = 'id' +ATTR_PARSER = 'parse_mode' ATTR_PASSWORD = 'password' +ATTR_REPLY_TO_MSGID = 'reply_to_message_id' +ATTR_REPLYMARKUP = 'reply_markup' +ATTR_SHOW_ALERT = 'show_alert' +ATTR_TARGET = 'target' +ATTR_TEXT = 'text' +ATTR_URL = 'url' +ATTR_USER_ID = 'user_id' +ATTR_USERNAME = 'username' + CONF_ALLOWED_CHAT_IDS = 'allowed_chat_ids' CONF_TRUSTED_NETWORKS = 'trusted_networks' + +DOMAIN = 'telegram_bot' + +EVENT_TELEGRAM_CALLBACK = 'telegram_callback' +EVENT_TELEGRAM_COMMAND = 'telegram_command' +EVENT_TELEGRAM_TEXT = 'telegram_text' + +PARSER_HTML = 'html' +PARSER_MD = 'markdown' + DEFAULT_TRUSTED_NETWORKS = [ ip_network('149.154.167.197/32'), ip_network('149.154.167.198/31'), @@ -81,7 +85,7 @@ CONFIG_SCHEMA = vol.Schema({ 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({ diff --git a/requirements_all.txt b/requirements_all.txt index af7e9b5255b..39216567652 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -684,7 +684,7 @@ python-roku==3.1.3 python-synology==0.1.0 # homeassistant.components.telegram_bot -python-telegram-bot==5.3.1 +python-telegram-bot==6.0.1 # homeassistant.components.sensor.twitch python-twitch==1.3.0 From 783abc799611b11b86c93af97ee615d1967cfff7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 22 May 2017 15:17:15 +0200 Subject: [PATCH 030/105] Make 'sender' as requirement for the config (fixes #7698) (#7706) --- homeassistant/components/notify/smtp.py | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index d66d024e111..6489345d91c 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -12,16 +12,17 @@ from email.mime.image import MIMEImage from email.mime.application import MIMEApplication import email.utils import os + import voluptuous as vol +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_TIMEOUT, CONF_SENDER, CONF_RECIPIENT) -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -42,10 +43,10 @@ DEFAULT_STARTTLS = False # pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]), + vol.Required(CONF_SENDER): vol.Email(), vol.Optional(CONF_SERVER, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_SENDER): vol.Email(), vol.Optional(CONF_STARTTLS, default=DEFAULT_STARTTLS): cv.boolean, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, @@ -75,11 +76,11 @@ def get_service(hass, config, discovery_info=None): class MailNotificationService(BaseNotificationService): - """Implement the notification service for E-Mail messages.""" + """Implement the notification service for E-mail messages.""" def __init__(self, server, port, timeout, sender, starttls, username, password, recipients, sender_name, debug): - """Initialize the service.""" + """Initialize the SMTP service.""" self._server = server self._port = port self._timeout = timeout @@ -142,11 +143,11 @@ class MailNotificationService(BaseNotificationService): if data: if ATTR_HTML in data: - msg = _build_html_msg(message, data[ATTR_HTML], - images=data.get(ATTR_IMAGES)) + msg = _build_html_msg( + message, data[ATTR_HTML], images=data.get(ATTR_IMAGES)) else: - msg = _build_multipart_msg(message, - images=data.get(ATTR_IMAGES)) + msg = _build_multipart_msg( + message, images=data.get(ATTR_IMAGES)) else: msg = _build_text_msg(message) @@ -167,8 +168,7 @@ class MailNotificationService(BaseNotificationService): mail = self.connect() for _ in range(self.tries): try: - mail.sendmail(self._sender, self.recipients, - msg.as_string()) + mail.sendmail(self._sender, self.recipients, msg.as_string()) break except smtplib.SMTPServerDisconnected: _LOGGER.warning( @@ -210,7 +210,7 @@ def _build_multipart_msg(message, images): msg.attach(attachment) attachment.add_header('Content-ID', '<{}>'.format(cid)) except TypeError: - _LOGGER.warning("Attachment %s has an unkown MIME type. " + _LOGGER.warning("Attachment %s has an unknown MIME type. " "Falling back to file", atch_name) attachment = MIMEApplication(file_bytes, Name=atch_name) attachment['Content-Disposition'] = ('attachment; ' @@ -226,8 +226,8 @@ def _build_multipart_msg(message, images): 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") + """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')) @@ -242,6 +242,6 @@ def _build_html_msg(text, html, images): msg.attach(attachment) attachment.add_header('Content-ID', '<{}>'.format(name)) except FileNotFoundError: - _LOGGER.warning('Attachment %s [#%s] not found. Skipping', + _LOGGER.warning("Attachment %s [#%s] not found. Skipping", atch_name, atch_num) return msg From 17cbe0c6ced00bb5ed46499b49fbddf51eba1169 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 22 May 2017 11:00:02 -0700 Subject: [PATCH 031/105] Allow fetching hass.io panel without auth (#7714) --- homeassistant/components/hassio.py | 7 +++- tests/components/test_hassio.py | 59 ++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index ed7e13a2969..e33a387eada 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -16,7 +16,7 @@ from aiohttp.hdrs import CONTENT_TYPE import async_timeout from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.components.frontend import register_built_in_panel @@ -139,7 +139,7 @@ class HassIOView(HomeAssistantView): name = "api:hassio" url = "/api/hassio/{path:.+}" - requires_auth = True + requires_auth = False def __init__(self, hassio): """Initialize a hassio base view.""" @@ -148,6 +148,9 @@ class HassIOView(HomeAssistantView): @asyncio.coroutine def _handle(self, request, path): """Route data to hassio.""" + if path != 'panel' and not request[KEY_AUTHENTICATED]: + return web.Response(status=401) + client = yield from self.hassio.command_proxy(path, request) data = yield from client.read() diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index 658e78b4523..eb0754fdc0a 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -5,9 +5,12 @@ from unittest.mock import patch, Mock, MagicMock import pytest +from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component -from tests.common import mock_coro, mock_http_component_app +from tests.common import mock_coro + +API_PASSWORD = 'pass1234' @pytest.fixture @@ -22,10 +25,12 @@ def hassio_env(): @pytest.fixture def hassio_client(hassio_env, hass, test_client): """Create mock hassio http client.""" - app = mock_http_component_app(hass) - hass.loop.run_until_complete(async_setup_component(hass, 'hassio', {})) - hass.http.views['api:hassio'].register(app.router) - yield hass.loop.run_until_complete(test_client(app)) + hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': API_PASSWORD + } + })) + yield hass.loop.run_until_complete(test_client(hass.http.app)) @asyncio.coroutine @@ -56,7 +61,40 @@ def test_forward_request(hassio_client): Mock(return_value=mock_coro(response))), \ patch('homeassistant.components.hassio._create_response') as mresp: mresp.return_value = 'response' - resp = yield from hassio_client.post('/api/hassio/beer') + resp = yield from hassio_client.post('/api/hassio/beer', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_auth_required_forward_request(hassio_client): + """Test auth required for normal request.""" + resp = yield from hassio_client.post('/api/hassio/beer') + + # Check we got right response + assert resp.status == 401 + + +@asyncio.coroutine +def test_forward_request_no_auth_for_panel(hassio_client): + """Test no auth needed for .""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIO.command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio._create_response') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.get('/api/hassio/panel') # Check we got right response assert resp.status == 200 @@ -79,7 +117,9 @@ def test_forward_log_request(hassio_client): patch('homeassistant.components.hassio.' '_create_response_log') as mresp: mresp.return_value = 'response' - resp = yield from hassio_client.get('/api/hassio/beer/logs') + resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) # Check we got right response assert resp.status == 200 @@ -96,5 +136,8 @@ def test_bad_gateway_when_cannot_find_supervisor(hassio_client): """Test we get a bad gateway error if we can't find supervisor.""" with patch('homeassistant.components.hassio.async_timeout.timeout', side_effect=asyncio.TimeoutError): - resp = yield from hassio_client.get('/api/hassio/addons/test/info') + resp = yield from hassio_client.get( + '/api/hassio/addons/test/info', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) assert resp.status == 502 From 6872daab8997865fcac5971a780082c9d231a499 Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Mon, 22 May 2017 17:00:41 -0700 Subject: [PATCH 032/105] update apcacccess used in apcupsd to 0.0.10, which fixes random file drop from apcaccess (#7722) --- homeassistant/components/apcupsd.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apcupsd.py b/homeassistant/components/apcupsd.py index 72db3e06dee..b2423d44623 100644 --- a/homeassistant/components/apcupsd.py +++ b/homeassistant/components/apcupsd.py @@ -13,7 +13,7 @@ from homeassistant.const import (CONF_HOST, CONF_PORT) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['apcaccess==0.0.4'] +REQUIREMENTS = ['apcaccess==0.0.10'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 39216567652..a577999c95d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -62,7 +62,7 @@ amcrest==1.2.0 anthemav==1.1.8 # homeassistant.components.apcupsd -apcaccess==0.0.4 +apcaccess==0.0.10 # homeassistant.components.notify.apns apns2==0.1.1 From 2682996939ff203e8ad765c2fdac8200052af836 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 23 May 2017 06:45:22 -0700 Subject: [PATCH 033/105] Constrain requests to a version (#7725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- 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 39314f963ae..d65d9438716 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -requests>=2,<3 +requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=7.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index a577999c95d..61bb7d82d72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,5 +1,5 @@ # Home Assistant core -requests>=2,<3 +requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=7.1.0 diff --git a/setup.py b/setup.py index 2cdcad544fb..d067a9019b3 100755 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ DOWNLOAD_URL = ('{}/archive/' PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ - 'requests>=2,<3', + 'requests==2.14.2', 'pyyaml>=3.11,<4', 'pytz>=2017.02', 'pip>=7.1.0', From c556b619b79341be699b24a194f0416cbc529c7a Mon Sep 17 00:00:00 2001 From: Lev Aronsky Date: Tue, 23 May 2017 19:55:01 +0300 Subject: [PATCH 034/105] Asuswrt continuous ssh (#7728) * Make ssh and telnet connections continuous in asuswrt * Refactored SSH and Telnet connections into respective classes. * Fixed several copy-paste typos and errors. * More typos fixed. * Small changes to arguments, to pass automated tests. * Removed unsupported named arguments. * Fixed a couple of mistakes in Telnet, and other lint errors. * Added Telnet tests, and added lint exceptions. * Removed comments from tests, as they irritated the hound. --- .../components/device_tracker/asuswrt.py | 301 ++++++++++++------ .../components/device_tracker/test_asuswrt.py | 82 ++++- 2 files changed, 274 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index a0405b0b690..cc50ab44e54 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -118,25 +118,29 @@ class AsusWrtDeviceScanner(DeviceScanner): self.protocol = config[CONF_PROTOCOL] self.mode = config[CONF_MODE] self.port = config[CONF_PORT] - self.ssh_args = {} if self.protocol == 'ssh': - - self.ssh_args['port'] = self.port - if self.ssh_key: - self.ssh_args['ssh_key'] = self.ssh_key - elif self.password: - self.ssh_args['password'] = self.password - else: + if not (self.ssh_key or self.password): _LOGGER.error("No password or private key specified") self.success_init = False return + + self.connection = SshConnection(self.host, self.port, + self.username, + self.password, + self.ssh_key, + self.mode == "ap") else: if not self.password: _LOGGER.error("No password specified") self.success_init = False return + self.connection = TelnetConnection(self.host, self.port, + self.username, + self.password, + self.mode == "ap") + self.lock = threading.Lock() self.last_results = {} @@ -182,105 +186,9 @@ class AsusWrtDeviceScanner(DeviceScanner): self.last_results = active_clients return True - def ssh_connection(self): - """Retrieve data from ASUSWRT via the ssh protocol.""" - from pexpect import pxssh, exceptions - - ssh = pxssh.pxssh() - try: - ssh.login(self.host, self.username, **self.ssh_args) - except exceptions.EOF as err: - _LOGGER.error("Connection refused. SSH enabled?") - return None - except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unable to connect via SSH: %s", str(err)) - return None - - try: - ssh.sendline(_IP_NEIGH_CMD) - ssh.prompt() - neighbors = ssh.before.split(b'\n')[1:-1] - if self.mode == 'ap': - ssh.sendline(_ARP_CMD) - ssh.prompt() - arp_result = ssh.before.split(b'\n')[1:-1] - ssh.sendline(_WL_CMD) - ssh.prompt() - leases_result = ssh.before.split(b'\n')[1:-1] - ssh.sendline(_NVRAM_CMD) - ssh.prompt() - nvram_result = ssh.before.split(b'\n')[1].split(b'<')[1:] - else: - arp_result = [''] - nvram_result = [''] - ssh.sendline(_LEASES_CMD) - ssh.prompt() - leases_result = ssh.before.split(b'\n')[1:-1] - ssh.logout() - return AsusWrtResult(neighbors, leases_result, arp_result, - nvram_result) - except pxssh.ExceptionPxssh as exc: - _LOGGER.error("Unexpected response from router: %s", exc) - return None - - def telnet_connection(self): - """Retrieve data from ASUSWRT via the telnet protocol.""" - try: - telnet = telnetlib.Telnet(self.host) - telnet.read_until(b'login: ') - telnet.write((self.username + '\n').encode('ascii')) - telnet.read_until(b'Password: ') - telnet.write((self.password + '\n').encode('ascii')) - prompt_string = telnet.read_until(b'#').split(b'\n')[-1] - telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii')) - neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1] - if self.mode == 'ap': - telnet.write('{}\n'.format(_ARP_CMD).encode('ascii')) - arp_result = (telnet.read_until(prompt_string). - split(b'\n')[1:-1]) - telnet.write('{}\n'.format(_WL_CMD).encode('ascii')) - leases_result = (telnet.read_until(prompt_string). - split(b'\n')[1:-1]) - telnet.write('{}\n'.format(_NVRAM_CMD).encode('ascii')) - nvram_result = (telnet.read_until(prompt_string). - split(b'\n')[1].split(b'<')[1:]) - else: - arp_result = [''] - nvram_result = [''] - telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) - leases_result = (telnet.read_until(prompt_string). - split(b'\n')[1:-1]) - telnet.write('exit\n'.encode('ascii')) - return AsusWrtResult(neighbors, leases_result, arp_result, - nvram_result) - except EOFError: - _LOGGER.error("Unexpected response from router") - return None - except ConnectionRefusedError: - _LOGGER.error("Connection refused by router. Telnet enabled?") - return None - except socket.gaierror as exc: - _LOGGER.error("Socket exception: %s", exc) - return None - except OSError as exc: - _LOGGER.error("OSError: %s", exc) - return None - def get_asuswrt_data(self): """Retrieve data from ASUSWRT and return parsed result.""" - if self.protocol == 'ssh': - result = self.ssh_connection() - elif self.protocol == 'telnet': - result = self.telnet_connection() - else: - # autodetect protocol - result = self.ssh_connection() - if result: - self.protocol = 'ssh' - else: - result = self.telnet_connection() - if result: - self.protocol = 'telnet' + result = self.connection.get_result() if not result: return {} @@ -363,3 +271,186 @@ class AsusWrtDeviceScanner(DeviceScanner): if match.group('ip') in devices: devices[match.group('ip')]['status'] = match.group('status') return devices + + +class _Connection: + def __init__(self): + self._connected = False + + @property + def connected(self): + """Return connection state.""" + return self._connected + + def connect(self): + """Mark currenct connection state as connected.""" + self._connected = True + + def disconnect(self): + """Mark current connection state as disconnected.""" + self._connected = False + + +class SshConnection(_Connection): + """Maintains an SSH connection to an ASUS-WRT router.""" + + def __init__(self, host, port, username, password, ssh_key, ap): + """Initialize the SSH connection properties.""" + from pexpect import pxssh + + super(SshConnection, self).__init__() + + self._ssh = pxssh.pxssh() + self._host = host + self._port = port + self._username = username + self._password = password + self._ssh_key = ssh_key + self._ap = ap + + def get_result(self): + """Retrieve a single AsusWrtResult through an SSH connection. + + Connect to the SSH server if not currently connected, otherwise + use the existing connection. + """ + from pexpect import pxssh, exceptions + + try: + if not self.connected: + self.connect() + self._ssh.sendline(_IP_NEIGH_CMD) + self._ssh.prompt() + neighbors = self._ssh.before.split(b'\n')[1:-1] + if self._ap: + self._ssh.sendline(_ARP_CMD) + self._ssh.prompt() + arp_result = self._ssh.before.split(b'\n')[1:-1] + self._ssh.sendline(_WL_CMD) + self._ssh.prompt() + leases_result = self._ssh.before.split(b'\n')[1:-1] + self._ssh.sendline(_NVRAM_CMD) + self._ssh.prompt() + nvram_result = self._ssh.before.split(b'\n')[1].split(b'<')[1:] + else: + arp_result = [''] + nvram_result = [''] + self._ssh.sendline(_LEASES_CMD) + self._ssh.prompt() + leases_result = self._ssh.before.split(b'\n')[1:-1] + return AsusWrtResult(neighbors, leases_result, arp_result, + nvram_result) + except exceptions.EOF as err: + _LOGGER.error("Connection refused. SSH enabled?") + self.disconnect() + return None + except pxssh.ExceptionPxssh as err: + _LOGGER.error("Unexpected SSH error: %s", str(err)) + self.disconnect() + return None + + def connect(self): + """Connect to the ASUS-WRT SSH server.""" + if self._ssh_key: + self._ssh.login(self._host, self._username, + ssh_key=self._ssh_key, port=self._port) + else: + self._ssh.login(self._host, self._username, + password=self._password, port=self._port) + + super(SshConnection, self).connect() + + def disconnect(self): \ + # pylint: disable=broad-except + """Disconnect the current SSH connection.""" + try: + self._ssh.logout() + except Exception: + pass + + super(SshConnection, self).disconnect() + + +class TelnetConnection(_Connection): + """Maintains a Telnet connection to an ASUS-WRT router.""" + + def __init__(self, host, port, username, password, ap): + """Initialize the Telnet connection properties.""" + super(TelnetConnection, self).__init__() + + self._telnet = None + self._host = host + self._port = port + self._username = username + self._password = password + self._ap = ap + self._prompt_string = None + + def get_result(self): + """Retrieve a single AsusWrtResult through a Telnet connection. + + Connect to the Telnet server if not currently connected, otherwise + use the existing connection. + """ + try: + if not self.connected: + self.connect() + + self._telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii')) + neighbors = (self._telnet.read_until(self._prompt_string). + split(b'\n')[1:-1]) + if self._ap: + self._telnet.write('{}\n'.format(_ARP_CMD).encode('ascii')) + arp_result = (self._telnet.read_until(self._prompt_string). + split(b'\n')[1:-1]) + self._telnet.write('{}\n'.format(_WL_CMD).encode('ascii')) + leases_result = (self._telnet.read_until(self._prompt_string). + split(b'\n')[1:-1]) + self._telnet.write('{}\n'.format(_NVRAM_CMD).encode('ascii')) + nvram_result = (self._telnet.read_until(self._prompt_string). + split(b'\n')[1].split(b'<')[1:]) + else: + arp_result = [''] + nvram_result = [''] + self._telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) + leases_result = (self._telnet.read_until(self._prompt_string). + split(b'\n')[1:-1]) + return AsusWrtResult(neighbors, leases_result, arp_result, + nvram_result) + except EOFError: + _LOGGER.error("Unexpected response from router") + self.disconnect() + return None + except ConnectionRefusedError: + _LOGGER.error("Connection refused by router. Telnet enabled?") + self.disconnect() + return None + except socket.gaierror as exc: + _LOGGER.error("Socket exception: %s", exc) + self.disconnect() + return None + except OSError as exc: + _LOGGER.error("OSError: %s", exc) + self.disconnect() + return None + + def connect(self): + """Connect to the ASUS-WRT Telnet server.""" + self._telnet = telnetlib.Telnet(self._host) + self._telnet.read_until(b'login: ') + self._telnet.write((self._username + '\n').encode('ascii')) + self._telnet.read_until(b'Password: ') + self._telnet.write((self._password + '\n').encode('ascii')) + self._prompt_string = self._telnet.read_until(b'#').split(b'\n')[-1] + + super(TelnetConnection, self).connect() + + def disconnect(self): \ + # pylint: disable=broad-except + """Disconnect the current Telnet connection.""" + try: + self._telnet.write('exit\n'.encode('ascii')) + except Exception: + pass + + super(TelnetConnection, self).disconnect() diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 81d3c7a1900..0de5ac67a30 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -135,11 +135,12 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): update_mock.start() self.addCleanup(update_mock.stop) asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.ssh_connection() + asuswrt.connection.get_result() self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, - mock.call('fake_host', 'fake_user', port=22, ssh_key=FAKEFILE) + mock.call('fake_host', 'fake_user', + ssh_key=FAKEFILE, port=22) ) def test_ssh_login_with_password(self): @@ -160,11 +161,12 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): update_mock.start() self.addCleanup(update_mock.stop) asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.ssh_connection() + asuswrt.connection.get_result() self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, - mock.call('fake_host', 'fake_user', password='fake_pass', port=22) + mock.call('fake_host', 'fake_user', + password='fake_pass', port=22) ) def test_ssh_login_without_password_or_pubkey(self): \ @@ -194,3 +196,75 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): assert setup_component(self.hass, DOMAIN, {DOMAIN: conf_dict}) ssh.login.assert_not_called() + + def test_telnet_login_with_password(self): + """Test that login is done with password when configured to.""" + telnet = mock.MagicMock() + telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) + telnet_mock.start() + self.addCleanup(telnet_mock.stop) + conf_dict = PLATFORM_SCHEMA({ + CONF_PLATFORM: 'asuswrt', + CONF_PROTOCOL: 'telnet', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass' + }) + update_mock = mock.patch( + 'homeassistant.components.device_tracker.asuswrt.' + 'AsusWrtDeviceScanner.get_asuswrt_data') + update_mock.start() + self.addCleanup(update_mock.stop) + asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) + asuswrt.connection.get_result() + self.assertEqual(telnet.read_until.call_count, 5) + self.assertEqual(telnet.write.call_count, 4) + self.assertEqual( + telnet.read_until.call_args_list[0], + mock.call(b'login: ') + ) + self.assertEqual( + telnet.write.call_args_list[0], + mock.call(b'fake_user\n') + ) + self.assertEqual( + telnet.read_until.call_args_list[1], + mock.call(b'Password: ') + ) + self.assertEqual( + telnet.write.call_args_list[1], + mock.call(b'fake_pass\n') + ) + self.assertEqual( + telnet.read_until.call_args_list[2], + mock.call(b'#') + ) + + def test_telnet_login_without_password(self): \ + # pylint: disable=invalid-name + """Test that login is not called without password or pub_key.""" + telnet = mock.MagicMock() + telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) + telnet_mock.start() + self.addCleanup(telnet_mock.stop) + + conf_dict = { + CONF_PLATFORM: 'asuswrt', + CONF_PROTOCOL: 'telnet', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + } + + with self.assertRaises(vol.Invalid): + conf_dict = PLATFORM_SCHEMA(conf_dict) + + update_mock = mock.patch( + 'homeassistant.components.device_tracker.asuswrt.' + 'AsusWrtDeviceScanner.get_asuswrt_data') + update_mock.start() + self.addCleanup(update_mock.stop) + + with assert_setup_component(0): + assert setup_component(self.hass, DOMAIN, + {DOMAIN: conf_dict}) + telnet.login.assert_not_called() From 228fb8c072a0422c4cd14fb5907fa2a90afa54bc Mon Sep 17 00:00:00 2001 From: Brenton Zillins Date: Tue, 23 May 2017 10:16:54 -0700 Subject: [PATCH 035/105] Ensure https base_url in telegram bot (#7726) --- homeassistant/components/telegram_bot/webhooks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 690340fc378..928f40b4ffc 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -42,6 +42,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.debug("telegram webhook Status: %s", current_status) handler_url = '{0}{1}'.format(hass.config.api.base_url, TELEGRAM_HANDLER_URL) + if not handler_url.startswith('https'): + _LOGGER.error("Invalid telegram webhook %s must be https", handler_url) + return False + if current_status and current_status['url'] != handler_url: result = yield from hass.async_add_job(bot.setWebhook, handler_url) if result: From f3dabe21ab78c51c13193767087cf5ea2427e214 Mon Sep 17 00:00:00 2001 From: Anton Sarukhanov Date: Tue, 23 May 2017 10:32:06 -0700 Subject: [PATCH 036/105] Prevent the random template filter from caching its output. Fixes #5678 (#7716) --- homeassistant/helpers/template.py | 13 +++++++++++++ tests/helpers/test_template.py | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index dafa77da972..77d0819e10d 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2,9 +2,11 @@ from datetime import datetime import json import logging +import random import re import jinja2 +from jinja2 import contextfilter from jinja2.sandbox import ImmutableSandboxedEnvironment from homeassistant.const import ( @@ -418,6 +420,16 @@ def forgiving_float(value): return value +@contextfilter +def random_every_time(context, values): + """Choose a random value. + + Unlike Jinja's random filter, + this is context-dependent to avoid caching the chosen value. + """ + return random.choice(values) + + class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" @@ -435,6 +447,7 @@ ENV.filters['timestamp_utc'] = timestamp_utc ENV.filters['is_defined'] = fail_when_undefined ENV.filters['max'] = max ENV.filters['min'] = min +ENV.filters['random'] = random_every_time ENV.globals['float'] = forgiving_float ENV.globals['now'] = dt_util.now ENV.globals['utcnow'] = dt_util.utcnow diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 71075124f32..117ab971f9d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1,6 +1,7 @@ """Test Home Assistant template helper methods.""" from datetime import datetime import unittest +import random from unittest.mock import patch from homeassistant.components import group @@ -232,6 +233,15 @@ class TestHelpersTemplate(unittest.TestCase): self.assertEqual("1706951424.0", template.Template(tpl, self.hass).render()) + @patch.object(random, 'choice') + def test_random_every_time(self, test_choice): + """Ensure the random filter runs every time, not just once.""" + tpl = template.Template('{{ [1,2] | random }}', self.hass) + test_choice.return_value = 'foo' + self.assertEqual('foo', tpl.render()) + test_choice.return_value = 'bar' + self.assertEqual('bar', tpl.render()) + def test_passing_vars_as_keywords(self): """Test passing variables as keywords.""" self.assertEqual( From be53cc70682ca55ec983630d283d3afbe2736a02 Mon Sep 17 00:00:00 2001 From: nordeep Date: Tue, 23 May 2017 21:08:12 +0300 Subject: [PATCH 037/105] Don't initialize mqtt components which have already been discovered (#7625) * Don't initialize mqtt components which have already been discovered * Fix string length * Fix blank lines, fix constant name * Remove globals. Remove JSON dump * Add tests. Update grammar * PEP8 style issue * Add hyphen to object_id regex * PEP8 style fix --- homeassistant/components/mqtt/discovery.py | 18 +++++++++++++++++- tests/components/mqtt/test_discovery.py | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index dbee9dce571..3ead94dca14 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -17,7 +17,8 @@ from homeassistant.components.mqtt import CONF_STATE_TOPIC _LOGGER = logging.getLogger(__name__) TOPIC_MATCHER = re.compile( - r'(?P\w+)/(?P\w+)/(?P\w+)/config') + r'(?P\w+)/(?P\w+)/(?P[a-zA-Z0-9_-]+)' + '/config') SUPPORTED_COMPONENTS = ['binary_sensor', 'light', 'sensor', 'switch'] @@ -28,6 +29,8 @@ ALLOWED_PLATFORMS = { 'switch': ['mqtt'], } +ALREADY_DISCOVERED = 'mqtt_discovered_components' + @asyncio.coroutine def async_start(hass, discovery_topic, hass_config): @@ -65,6 +68,19 @@ def async_start(hass, discovery_topic, hass_config): payload[CONF_STATE_TOPIC] = '{}/{}/{}/state'.format( discovery_topic, component, object_id) + if ALREADY_DISCOVERED not in hass.data: + hass.data[ALREADY_DISCOVERED] = set() + + discovery_hash = (component, object_id) + if discovery_hash in hass.data[ALREADY_DISCOVERED]: + _LOGGER.info("Component has already been discovered: %s %s", + component, object_id) + return + + hass.data[ALREADY_DISCOVERED].add(discovery_hash) + + _LOGGER.info("Found new component: %s %s", component, object_id) + yield from async_load_platform( hass, component, platform, payload, hass_config) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 134b679daea..04ea0f34fd5 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -73,3 +73,24 @@ def test_correct_config_discovery(hass, mqtt_mock, caplog): assert state is not None assert state.name == 'Beer' + + +@asyncio.coroutine +def test_non_duplicate_discovery(hass, mqtt_mock, caplog): + """Test for a non duplicate component.""" + yield from async_start(hass, 'homeassistant', {}) + + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + '{ "name": "Beer" }') + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + '{ "name": "Beer" }') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.beer') + state_duplicate = hass.states.get('binary_sensor.beer1') + + assert state is not None + assert state.name == 'Beer' + assert state_duplicate is None + assert 'Component has already been discovered: ' \ + 'binary_sensor bla' in caplog.text From fce09f624b49b9cd8556368c07bf6856e11b133b Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 23 May 2017 22:35:19 +0200 Subject: [PATCH 038/105] LIFX: disable color features for white-only bulbs (#7742) The product type is already established in order to decide the Kelvin range so just reuse that information to disable color features for white-only lights. Also change the breathe/pulse effects to be more useful for white-only bulbs. For consistency, color bulbs set to a desaturated (i.e. white-ish) color get the same default treatment as white-only bulbs. --- .../components/light/lifx/__init__.py | 25 ++++++++----- .../components/light/lifx/effects.py | 37 ++++++++++++------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/light/lifx/__init__.py b/homeassistant/components/light/lifx/__init__.py index c264fec35c5..ea2d4f0281f 100644 --- a/homeassistant/components/light/lifx/__init__.py +++ b/homeassistant/components/light/lifx/__init__.py @@ -54,9 +54,6 @@ ATTR_POWER = 'power' BYTE_MAX = 255 SHORT_MAX = 65535 -SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR | - SUPPORT_XY_COLOR | SUPPORT_TRANSITION | SUPPORT_EFFECT) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SERVER, default='0.0.0.0'): cv.string, }) @@ -229,6 +226,12 @@ class LIFXLight(Light): self.set_power(device.power_level) self.set_color(*device.color) + @property + def lifxwhite(self): + """Return whether this is a white-only bulb.""" + # https://lan.developer.lifx.com/docs/lifx-products + return self.product in [10, 11, 18] + @property def available(self): """Return the availability of the device.""" @@ -273,8 +276,7 @@ class LIFXLight(Light): def min_mireds(self): """Return the coldest color_temp that this light supports.""" # The 3 LIFX "White" products supported a limited temperature range - # https://lan.developer.lifx.com/docs/lifx-products - if self.product in [10, 11, 18]: + if self.lifxwhite: kelvin = 6500 else: kelvin = 9000 @@ -284,8 +286,7 @@ class LIFXLight(Light): def max_mireds(self): """Return the warmest color_temp that this light supports.""" # The 3 LIFX "White" products supported a limited temperature range - # https://lan.developer.lifx.com/docs/lifx-products - if self.product in [10, 11, 18]: + if self.lifxwhite: kelvin = 2700 else: kelvin = 2500 @@ -305,12 +306,18 @@ class LIFXLight(Light): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_LIFX + features = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | + SUPPORT_TRANSITION | SUPPORT_EFFECT) + + if not self.lifxwhite: + features |= SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR + + return features @property def effect_list(self): """Return the list of supported effects.""" - return lifx_effects.effect_list() + return lifx_effects.effect_list(self) @asyncio.coroutine def update_after_transition(self, now): diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py index 0a8c9cbf80f..2595bcb32e6 100644 --- a/homeassistant/components/light/lifx/effects.py +++ b/homeassistant/components/light/lifx/effects.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.light import ( DOMAIN, ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, - ATTR_RGB_COLOR, ATTR_EFFECT, ATTR_TRANSITION, + ATTR_RGB_COLOR, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_EFFECT, ATTR_TRANSITION, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT) from homeassistant.const import (ATTR_ENTITY_ID) import homeassistant.helpers.config_validation as cv @@ -42,6 +42,8 @@ LIFX_EFFECT_BREATHE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ ATTR_COLOR_NAME: cv.string, ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), 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)), vol.Optional(ATTR_PERIOD, default=1.0): vol.All(vol.Coerce(float), vol.Range(min=0.05)), vol.Optional(ATTR_CYCLES, default=1.0): @@ -131,14 +133,21 @@ def default_effect(light, **kwargs): yield from light.hass.services.async_call(DOMAIN, service, data) -def effect_list(): - """Return the list of supported effects.""" - return [ - SERVICE_EFFECT_COLORLOOP, - SERVICE_EFFECT_BREATHE, - SERVICE_EFFECT_PULSE, - SERVICE_EFFECT_STOP, - ] +def effect_list(light): + """Return the list of supported effects for this light.""" + if light.lifxwhite: + return [ + SERVICE_EFFECT_BREATHE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_STOP, + ] + else: + return [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_BREATHE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_STOP, + ] class LIFXEffectData(object): @@ -230,12 +239,14 @@ class LIFXEffectBreathe(LIFXEffect): cycles = kwargs[ATTR_CYCLES] hsbk, color_changed = light.find_hsbk(**kwargs) - # Default color is to fully (de)saturate with full brightness + # Set default effect color based on current setting if not color_changed: - if hsbk[1] > 65536/2: - hsbk = [hsbk[0], 0, 65535, 4000] + if light.lifxwhite or hsbk[1] < 65536/2: + # White: toggle brightness + hsbk[2] = 65535 if hsbk[2] < 65536/2 else 0 else: - hsbk = [hsbk[0], 65535, 65535, hsbk[3]] + # Color: fully desaturate with full brightness + hsbk = [hsbk[0], 0, 65535, 4000] # Start the effect args = { From 7055fddfb47cc2a10fb1d4e8cb7da66cf0beb069 Mon Sep 17 00:00:00 2001 From: Anton Sarukhanov Date: Tue, 23 May 2017 14:29:27 -0700 Subject: [PATCH 039/105] Don't block startup more than 60 seconds while waiting for components. (#7739) --- homeassistant/helpers/entity_component.py | 15 +++++++++---- tests/common.py | 5 ++++- tests/helpers/test_entity_component.py | 26 ++++++++++++++++++++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2c1801a6342..9ebad3862f3 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -19,6 +19,7 @@ from homeassistant.util.async import ( DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) SLOW_SETUP_WARNING = 10 +SLOW_SETUP_MAX_WAIT = 60 class EntityComponent(object): @@ -145,20 +146,26 @@ class EntityComponent(object): try: if getattr(platform, 'async_setup_platform', None): - yield from platform.async_setup_platform( + task = platform.async_setup_platform( self.hass, platform_config, entity_platform.async_schedule_add_entities, discovery_info ) else: - yield from self.hass.loop.run_in_executor( + task = self.hass.loop.run_in_executor( None, platform.setup_platform, self.hass, platform_config, entity_platform.schedule_add_entities, discovery_info ) - + yield from asyncio.wait_for( + asyncio.shield(task, loop=self.hass.loop), + SLOW_SETUP_MAX_WAIT, loop=self.hass.loop) yield from entity_platform.async_block_entities_done() - self.hass.config.components.add( '{}.{}'.format(self.domain, platform_type)) + except asyncio.TimeoutError: + self.logger.error( + "Setup of platform %s is taking longer than %s seconds." + " Startup will proceed without waiting any longer.", + platform_type, SLOW_SETUP_MAX_WAIT) except Exception: # pylint: disable=broad-except self.logger.exception( "Error while setting up platform %s", platform_type) diff --git a/tests/common.py b/tests/common.py index 30bd772a81f..735b1dfce98 100644 --- a/tests/common.py +++ b/tests/common.py @@ -317,7 +317,7 @@ class MockPlatform(object): # pylint: disable=invalid-name def __init__(self, setup_platform=None, dependencies=None, - platform_schema=None): + platform_schema=None, async_setup_platform=None): """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] self._setup_platform = setup_platform @@ -325,6 +325,9 @@ class MockPlatform(object): if platform_schema is not None: self.PLATFORM_SCHEMA = platform_schema + if async_setup_platform is not None: + self.async_setup_platform = async_setup_platform + def setup_platform(self, hass, config, add_devices, discovery_info=None): """Set up the platform.""" if self._setup_platform is not None: diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index ade8c4ebd8a..566306d7fe7 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -13,6 +13,7 @@ from homeassistant.components import group from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import ( EntityComponent, DEFAULT_SCAN_INTERVAL, SLOW_SETUP_WARNING) +from homeassistant.helpers import entity_component from homeassistant.helpers import discovery import homeassistant.util.dt as dt_util @@ -472,7 +473,6 @@ def test_platform_warn_slow_setup(hass): } }) assert mock_call.called - assert len(mock_call.mock_calls) == 2 timeout, logger_method = mock_call.mock_calls[0][1][:2] @@ -482,6 +482,30 @@ def test_platform_warn_slow_setup(hass): assert mock_call().cancel.called +@asyncio.coroutine +def test_platform_error_slow_setup(hass, caplog): + """Don't block startup more than SLOW_SETUP_MAX_WAIT.""" + with patch.object(entity_component, 'SLOW_SETUP_MAX_WAIT', 0): + called = [] + + @asyncio.coroutine + def setup_platform(*args): + called.append(1) + yield from asyncio.sleep(1, loop=hass.loop) + + platform = MockPlatform(async_setup_platform=setup_platform) + component = EntityComponent(_LOGGER, DOMAIN, hass) + loader.set_component('test_domain.test_platform', platform) + yield from component.async_setup({ + DOMAIN: { + 'platform': 'test_platform', + } + }) + assert len(called) == 1 + assert 'test_domain.test_platform' not in hass.config.components + assert 'test_platform is taking longer than 0 seconds' in caplog.text + + @asyncio.coroutine def test_extract_from_service_available_device(hass): """Test the extraction of entity from service and device is available.""" From b5f20c9b648935420ff80c67794b6c0cec85d243 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Tue, 23 May 2017 17:49:20 -0400 Subject: [PATCH 040/105] Always return rgb color of bulbs (#7743) --- homeassistant/components/light/wink.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 1f046a2ec27..b936e9addd6 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -19,8 +19,6 @@ DEPENDENCIES = ['wink'] SUPPORT_WINK = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR -RGB_MODES = ['hsb', 'rgb'] - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink lights.""" @@ -62,8 +60,6 @@ class WinkLight(WinkDevice, Light): """Define current bulb color in RGB.""" if not self.wink.supports_hue_saturation(): return None - elif self.wink.color_model() not in RGB_MODES: - return False else: hue = self.wink.color_hue() saturation = self.wink.color_saturation() From e3307fb1c2eaf4ff05da8676d71d29b0500b312b Mon Sep 17 00:00:00 2001 From: Juggels Date: Tue, 23 May 2017 23:56:00 +0200 Subject: [PATCH 041/105] Redesign monitored variables for hp_ilo sensor (#7534) * Redesign monitored variables Allow generating specific sensors without the need for template sensors * Import 3rd party library inside update method * Remove jsonpath_rw dependency * Do not interfere with value_template or ilo_data output Do not interfere with value_template or ilo_data output, this is now the responsibility of the user and should be handled in `configuration.yaml` Fix UnusedImportStatement Fix newline after function docstring * Always output results to state --- homeassistant/components/sensor/hp_ilo.py | 67 +++++++++++++---------- homeassistant/const.py | 1 + 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/sensor/hp_ilo.py b/homeassistant/components/sensor/hp_ilo.py index 338a6e7aff5..2e578c64cd2 100644 --- a/homeassistant/components/sensor/hp_ilo.py +++ b/homeassistant/components/sensor/hp_ilo.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, CONF_NAME, - CONF_MONITORED_VARIABLES, STATE_ON, STATE_OFF) + CONF_MONITORED_VARIABLES, CONF_VALUE_TEMPLATE, CONF_SENSOR_TYPE, + CONF_UNIT_OF_MEASUREMENT) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -45,8 +46,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=['server_name']): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_MONITORED_VARIABLES, default=[]): + vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SENSOR_TYPE): + vol.All(cv.string, vol.In(SENSOR_TYPES)), + vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=None): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template + })]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) @@ -60,7 +67,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): login = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) monitored_variables = config.get(CONF_MONITORED_VARIABLES) - name = config.get(CONF_NAME) # Create a data fetcher to support all of the configured sensors. Then make # the first call to init the data and confirm we can connect. @@ -72,10 +78,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # Initialize and add all of the sensors. devices = [] - for ilo_type in monitored_variables: + for monitored_variable in monitored_variables: new_device = HpIloSensor( - hp_ilo_data=hp_ilo_data, sensor_type=SENSOR_TYPES.get(ilo_type), - client_name=name) + hass=hass, + hp_ilo_data=hp_ilo_data, + sensor_name='{} {}'.format( + config.get(CONF_NAME), monitored_variable[CONF_NAME]), + sensor_type=monitored_variable[CONF_SENSOR_TYPE], + sensor_value_template=monitored_variable[CONF_VALUE_TEMPLATE], + unit_of_measurement=monitored_variable[CONF_UNIT_OF_MEASUREMENT]) devices.append(new_device) add_devices(devices) @@ -84,15 +95,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class HpIloSensor(Entity): """Representation of a HP ILO sensor.""" - def __init__(self, hp_ilo_data, sensor_type, client_name): + def __init__(self, hass, hp_ilo_data, sensor_type, sensor_name, + sensor_value_template, unit_of_measurement): """Initialize the sensor.""" - self._name = '{} {}'.format(client_name, sensor_type[0]) - self._ilo_function = sensor_type[1] - self.client_name = client_name + self._hass = hass + self._name = sensor_name + self._unit_of_measurement = unit_of_measurement + self._ilo_function = SENSOR_TYPES[sensor_type][1] self.hp_ilo_data = hp_ilo_data + if sensor_value_template is not None: + sensor_value_template.hass = hass + self._sensor_value_template = sensor_value_template + self._state = None - self._data = None + self._state_attributes = None self.update() @@ -103,6 +120,11 @@ class HpIloSensor(Entity): """Return the name of the sensor.""" return self._name + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + @property def state(self): """Return the state of the sensor.""" @@ -111,7 +133,7 @@ class HpIloSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - return self._data + return self._state_attributes def update(self): """Get the latest data from HP ILO and updates the states.""" @@ -121,23 +143,10 @@ class HpIloSensor(Entity): self.hp_ilo_data.update() ilo_data = getattr(self.hp_ilo_data.data, self._ilo_function)() - # Store the data received from the ILO API - if isinstance(ilo_data, dict): - self._data = ilo_data - else: - self._data = {'value': ilo_data} + if self._sensor_value_template is not None: + ilo_data = self._sensor_value_template.render(ilo_data=ilo_data) - # If the data received is an integer or string, store it as - # the sensor state - if isinstance(ilo_data, (str, bytes)): - states = [STATE_ON, STATE_OFF] - try: - index_element = states.index(str(ilo_data).lower()) - self._state = states[index_element] - except ValueError: - self._state = ilo_data - elif isinstance(ilo_data, (int, float)): - self._state = ilo_data + self._state = ilo_data class HpIloData(object): diff --git a/homeassistant/const.py b/homeassistant/const.py index f198b20a3c0..2e886a992a2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -137,6 +137,7 @@ CONF_RGB = 'rgb' CONF_SCAN_INTERVAL = 'scan_interval' CONF_SENDER = 'sender' CONF_SENSOR_CLASS = 'sensor_class' +CONF_SENSOR_TYPE = 'sensor_type' CONF_SENSORS = 'sensors' CONF_SSL = 'ssl' CONF_STATE = 'state' From 54c45f80c1196e751d9ca7f00422bc60948b29a6 Mon Sep 17 00:00:00 2001 From: Stu Gott Date: Tue, 23 May 2017 19:00:26 -0400 Subject: [PATCH 042/105] Fix time_date sensor to update at predictable intervals (#7644) * Fix time_date sensor to update at predictable intervals * Delete automations.yaml --- homeassistant/components/sensor/time_date.py | 40 ++++++-- tests/components/sensor/test_time_date.py | 99 ++++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 tests/components/sensor/test_time_date.py diff --git a/homeassistant/components/sensor/time_date.py b/homeassistant/components/sensor/time_date.py index 97e6bfd4b3a..484be92ec47 100644 --- a/homeassistant/components/sensor/time_date.py +++ b/homeassistant/components/sensor/time_date.py @@ -10,11 +10,13 @@ import logging import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_DISPLAY_OPTIONS from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util +from homeassistant.helpers.event import async_track_point_in_utc_time _LOGGER = logging.getLogger(__name__) @@ -44,7 +46,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): devices = [] for variable in config[CONF_DISPLAY_OPTIONS]: - devices.append(TimeDateSensor(variable)) + device = TimeDateSensor(hass, variable) + async_track_point_in_utc_time( + hass, device.point_in_time_listener, device.get_next_interval()) + devices.append(device) async_add_devices(devices, True) return True @@ -53,11 +58,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class TimeDateSensor(Entity): """Implementation of a Time and Date sensor.""" - def __init__(self, option_type): + def __init__(self, hass, option_type): """Initialize the sensor.""" self._name = OPTION_TYPES[option_type] self.type = option_type self._state = None + self.hass = hass + + self._update_internal_state(dt_util.utcnow()) @property def name(self): @@ -79,10 +87,22 @@ class TimeDateSensor(Entity): else: return 'mdi:clock' - @asyncio.coroutine - def async_update(self): - """Get the latest data and updates the states.""" - time_date = dt_util.utcnow() + def get_next_interval(self, now=None): + """Compute next time an update should occur.""" + if now is None: + now = dt_util.utcnow() + if self.type == 'date': + now = dt_util.start_of_local_day(now) + return now + timedelta(seconds=86400) + elif self.type == 'beat': + interval = 86.4 + else: + interval = 60 + timestamp = int(dt_util.as_timestamp(now)) + delta = interval - (timestamp % interval) + return now + timedelta(seconds=delta) + + def _update_internal_state(self, time_date): time = dt_util.as_local(time_date).strftime(TIME_STR_FORMAT) time_utc = time_date.strftime(TIME_STR_FORMAT) date = dt_util.as_local(time_date).date().isoformat() @@ -106,3 +126,11 @@ class TimeDateSensor(Entity): self._state = time_utc elif self.type == 'beat': self._state = '@{0:03d}'.format(beat) + + @callback + def point_in_time_listener(self, time_date): + """Get the latest data and update state.""" + self._update_internal_state(time_date) + self.hass.async_add_job(self.async_update_ha_state()) + async_track_point_in_utc_time( + self.hass, self.point_in_time_listener, self.get_next_interval()) diff --git a/tests/components/sensor/test_time_date.py b/tests/components/sensor/test_time_date.py new file mode 100644 index 00000000000..98eb6e79428 --- /dev/null +++ b/tests/components/sensor/test_time_date.py @@ -0,0 +1,99 @@ +"""The tests for Kira sensor platform.""" +import unittest + +from homeassistant.components.sensor import time_date as time_date +import homeassistant.util.dt as dt_util + +from tests.common import get_test_home_assistant + + +class TestTimeDateSensor(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.DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE + + def tearDown(self): + """Stop everything that was started.""" + dt_util.set_default_time_zone(self.DEFAULT_TIME_ZONE) + self.hass.stop() + + # pylint: disable=protected-access + def test_intervals(self): + """Test timing intervals of sensors.""" + device = time_date.TimeDateSensor(self.hass, 'time') + now = dt_util.utc_from_timestamp(45) + next_time = device.get_next_interval(now) + assert next_time == dt_util.utc_from_timestamp(60) + + device = time_date.TimeDateSensor(self.hass, 'date') + now = dt_util.utc_from_timestamp(12345) + next_time = device.get_next_interval(now) + assert next_time == dt_util.utc_from_timestamp(86400) + + device = time_date.TimeDateSensor(self.hass, 'beat') + now = dt_util.utc_from_timestamp(29) + next_time = device.get_next_interval(now) + assert next_time == dt_util.utc_from_timestamp(86.4) + + device = time_date.TimeDateSensor(self.hass, 'date_time') + now = dt_util.utc_from_timestamp(1495068899) + next_time = device.get_next_interval(now) + assert next_time == dt_util.utc_from_timestamp(1495068900) + + now = dt_util.utcnow() + device = time_date.TimeDateSensor(self.hass, 'time_date') + next_time = device.get_next_interval() + assert next_time > now + + def test_states(self): + """Test states of sensors.""" + now = dt_util.utc_from_timestamp(1495068856) + device = time_date.TimeDateSensor(self.hass, 'time') + device._update_internal_state(now) + assert device.state == "00:54" + + device = time_date.TimeDateSensor(self.hass, 'date') + device._update_internal_state(now) + assert device.state == "2017-05-18" + + device = time_date.TimeDateSensor(self.hass, 'time_utc') + device._update_internal_state(now) + assert device.state == "00:54" + + device = time_date.TimeDateSensor(self.hass, 'beat') + device._update_internal_state(now) + assert device.state == "@079" + + # pylint: disable=no-member + def test_timezone_intervals(self): + """Test date sensor behavior in a timezone besides UTC.""" + new_tz = dt_util.get_time_zone('America/New_York') + assert new_tz is not None + dt_util.set_default_time_zone(new_tz) + + device = time_date.TimeDateSensor(self.hass, 'date') + now = dt_util.utc_from_timestamp(50000) + next_time = device.get_next_interval(now) + # start of local day in EST was 18000.0 + # so the second day was 18000 + 86400 + assert next_time.timestamp() == 104400 + + def test_icons(self): + """Test attributes of sensors.""" + device = time_date.TimeDateSensor(self.hass, 'time') + assert device.icon == "mdi:clock" + device = time_date.TimeDateSensor(self.hass, 'date') + assert device.icon == "mdi:calendar" + device = time_date.TimeDateSensor(self.hass, 'date_time') + assert device.icon == "mdi:calendar-clock" From 3638b21bcbab36bf4851d0faf427e97354457072 Mon Sep 17 00:00:00 2001 From: everix1992 Date: Tue, 23 May 2017 19:00:52 -0500 Subject: [PATCH 043/105] Added new commands and functionality to the harmony remote component. (#7113) * Added new commands and functionality to the harmony remote component. -This includes the ability to optionally specify a number of times to repeat a specific command, such as pressing the volume button multiple times. -Also added a new command that allows you to send multiple commands to the harmony at once, such as sending a set of channel numbers. -Updated the unit tests for these changes. * Fix flake8 coding violations * Remove send_commands command and make send_command handle a single or list of commands * Remove send_commands tests * Update itach and kira remotes for new send_command structure. Fix pyharmony version in requirements_all.txt * Fix incorrect variable name * Fix a couple minor issues with remote tests --- homeassistant/components/remote/__init__.py | 24 ++++++++++++++++--- homeassistant/components/remote/harmony.py | 12 ++++++---- homeassistant/components/remote/itach.py | 3 ++- homeassistant/components/remote/kira.py | 10 ++++---- homeassistant/components/remote/services.yaml | 8 ++++++- requirements_all.txt | 2 +- tests/components/remote/test_demo.py | 3 ++- tests/components/remote/test_init.py | 3 ++- tests/components/remote/test_kira.py | 4 ++-- 9 files changed, 49 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 6449015b712..a28ebd666f9 100755 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -26,6 +26,8 @@ _LOGGER = logging.getLogger(__name__) ATTR_ACTIVITY = 'activity' ATTR_COMMAND = 'command' ATTR_DEVICE = 'device' +ATTR_NUM_REPEATS = 'num_repeats' +ATTR_DELAY_SECS = 'delay_secs' DOMAIN = 'remote' @@ -40,6 +42,9 @@ SCAN_INTERVAL = timedelta(seconds=30) SERVICE_SEND_COMMAND = 'send_command' SERVICE_SYNC = 'sync' +DEFAULT_NUM_REPEATS = '1' +DEFAULT_DELAY_SECS = '0.4' + REMOTE_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_ENTITY_ID): cv.entity_ids, }) @@ -50,7 +55,9 @@ REMOTE_SERVICE_TURN_ON_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ REMOTE_SERVICE_SEND_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ vol.Required(ATTR_DEVICE): cv.string, - vol.Required(ATTR_COMMAND): cv.string, + vol.Required(ATTR_COMMAND): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS): cv.string, + vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): cv.string }) @@ -74,11 +81,19 @@ def turn_off(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) -def send_command(hass, device, command, entity_id=None): +def send_command(hass, device, command, entity_id=None, + num_repeats=None, delay_secs=None): """Send a command to a device.""" data = {ATTR_DEVICE: str(device), ATTR_COMMAND: command} if entity_id: data[ATTR_ENTITY_ID] = entity_id + + if num_repeats: + data[ATTR_NUM_REPEATS] = num_repeats + + if delay_secs: + data[ATTR_DELAY_SECS] = delay_secs + hass.services.call(DOMAIN, SERVICE_SEND_COMMAND, data) @@ -97,13 +112,16 @@ def async_setup(hass, config): activity_id = service.data.get(ATTR_ACTIVITY) device = service.data.get(ATTR_DEVICE) command = service.data.get(ATTR_COMMAND) + num_repeats = service.data.get(ATTR_NUM_REPEATS) + delay_secs = service.data.get(ATTR_DELAY_SECS) for remote in target_remotes: if service.service == SERVICE_TURN_ON: yield from remote.async_turn_on(activity=activity_id) elif service.service == SERVICE_SEND_COMMAND: yield from remote.async_send_command( - device=device, command=command) + device=device, command=command, + num_repeats=num_repeats, delay_secs=delay_secs) else: yield from remote.async_turn_off() diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 5a1e31bd0df..f0155cc4525 100755 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -15,11 +15,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_NAME, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID) from homeassistant.components.remote import ( - PLATFORM_SCHEMA, DOMAIN, ATTR_DEVICE, ATTR_COMMAND, ATTR_ACTIVITY) + PLATFORM_SCHEMA, DOMAIN, ATTR_DEVICE, ATTR_COMMAND, + ATTR_ACTIVITY, ATTR_NUM_REPEATS, ATTR_DELAY_SECS) from homeassistant.util import slugify from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyharmony==1.0.12'] +REQUIREMENTS = ['pyharmony==1.0.16'] _LOGGER = logging.getLogger(__name__) @@ -170,11 +171,12 @@ class HarmonyRemote(remote.RemoteDevice): pyharmony.ha_power_off(self._token, self._ip, self._port) def send_command(self, **kwargs): - """Send a command to one device.""" + """Send a set of commands to one device.""" import pyharmony - pyharmony.ha_send_command( + pyharmony.ha_send_commands( self._token, self._ip, self._port, kwargs[ATTR_DEVICE], - kwargs[ATTR_COMMAND]) + kwargs[ATTR_COMMAND], int(kwargs[ATTR_NUM_REPEATS]), + float(kwargs[ATTR_DELAY_SECS])) def sync(self): """Sync the Harmony device with the web service.""" diff --git a/homeassistant/components/remote/itach.py b/homeassistant/components/remote/itach.py index fa424576a11..e5a013a5dcf 100644 --- a/homeassistant/components/remote/itach.py +++ b/homeassistant/components/remote/itach.py @@ -104,7 +104,8 @@ class ITachIP2IRRemote(remote.RemoteDevice): def send_command(self, **kwargs): """Send a command to one device.""" - self.itachip2ir.send(self._name, kwargs[ATTR_COMMAND], 1) + for command in kwargs[ATTR_COMMAND]: + self.itachip2ir.send(self._name, command, 1) def update(self): """Update the device.""" diff --git a/homeassistant/components/remote/kira.py b/homeassistant/components/remote/kira.py index 3e816844a35..7ab73068cdb 100755 --- a/homeassistant/components/remote/kira.py +++ b/homeassistant/components/remote/kira.py @@ -64,11 +64,11 @@ class KiraRemote(Entity): 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) + for command in kwargs.get(remote.ATTR_COMMAND): + code_tuple = (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. diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 189377c503f..ff9cc3d3b16 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -30,8 +30,14 @@ send_command: description: Device ID to send command to example: '32756745' command: - description: Command to send + description: A single command or a list of commands to send. example: 'Play' + num_repeats: + description: An optional value that specifies the number of times you want to repeat the command(s). If not specified, the command(s) will not be repeated + example: '5' + delay_secs: + description: An optional value that specifies that number of seconds you want to wait in between repeated commands. If not specified, the default of 0.4 seconds will be used + example: '0.75' harmony_sync: description: Syncs the remote's configuration diff --git a/requirements_all.txt b/requirements_all.txt index 61bb7d82d72..7530489efd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ pyfttt==0.3 pygatt==3.1.1 # homeassistant.components.remote.harmony -pyharmony==1.0.12 +pyharmony==1.0.16 # homeassistant.components.binary_sensor.hikvision pyhik==0.1.2 diff --git a/tests/components/remote/test_demo.py b/tests/components/remote/test_demo.py index 0ede5d52a35..0c6fada4748 100755 --- a/tests/components/remote/test_demo.py +++ b/tests/components/remote/test_demo.py @@ -86,7 +86,8 @@ class TestDemoRemote(unittest.TestCase): remote.send_command( self.hass, entity_id='entity_id_val', - device='test_device', command='test_command') + device='test_device', command=['test_command'], + num_repeats='2', delay_secs='0.8') self.hass.block_till_done() diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 2cdbf9d9045..b4d2ff98688 100755 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -81,7 +81,8 @@ class TestRemote(unittest.TestCase): remote.send_command( self.hass, entity_id='entity_id_val', - device='test_device', command='test_command') + device='test_device', command=['test_command'], + num_repeats='4', delay_secs='0.6') self.hass.block_till_done() diff --git a/tests/components/remote/test_kira.py b/tests/components/remote/test_kira.py index 144504f8aa2..eaa78d44a60 100644 --- a/tests/components/remote/test_kira.py +++ b/tests/components/remote/test_kira.py @@ -49,9 +49,9 @@ class TestKiraSensor(unittest.TestCase): assert remote.name == 'kira' - command = "FAKE_COMMAND" + command = ["FAKE_COMMAND"] device = "FAKE_DEVICE" - commandTuple = (command, device) + commandTuple = (command[0], device) remote.send_command(device=device, command=command) self.mock_kira.sendCode.assert_called_with(commandTuple) From ef4ef2d3835e644f3bf13113a563d8da67e8a716 Mon Sep 17 00:00:00 2001 From: cribbstechnologies Date: Wed, 24 May 2017 14:32:22 -0400 Subject: [PATCH 044/105] Template light (#7657) * starting light template component * linting/flaking * starting unit tests from copypasta * working on unit testing * forgot to commit the test * wrapped up unit testing * adding remote back * updates post running tox * Revert "adding remote back" This reverts commit 852c87ff9694dfc48e92b74fd9dbafbc164a2393. * adding submodule back from origin * updating submodule * removing a line to commit * re-adding line * trying to update line endings * trying to fix line endings * trying a different approach * making requested changes, need to fix tests * flaking * union rather than intersect; makes a big difference * more tests passing, not sure why this one's failing * got it working * most of the requested changes * hopefully done now * sets; the more you know --- homeassistant/components/light/template.py | 236 ++++++++ tests/components/light/test_template.py | 637 +++++++++++++++++++++ 2 files changed, 873 insertions(+) create mode 100644 homeassistant/components/light/template.py create mode 100644 tests/components/light/test_template.py diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py new file mode 100644 index 00000000000..6854fac550e --- /dev/null +++ b/homeassistant/components/light/template.py @@ -0,0 +1,236 @@ +""" +Support for Template lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.template/ +""" +import logging +import asyncio + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ENTITY_ID_FORMAT, Light, SUPPORT_BRIGHTNESS) +from homeassistant.const import ( + CONF_VALUE_TEMPLATE, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_ON, + STATE_OFF, EVENT_HOMEASSISTANT_START, MATCH_ALL +) +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) +_VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false'] + +CONF_LIGHTS = 'lights' +CONF_ON_ACTION = 'turn_on' +CONF_OFF_ACTION = 'turn_off' +CONF_LEVEL_ACTION = 'set_level' +CONF_LEVEL_TEMPLATE = 'level_template' + + +LIGHT_SCHEMA = vol.Schema({ + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_LEVEL_ACTION, default=None): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_LIGHTS): vol.Schema({cv.slug: LIGHT_SCHEMA}), +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up Template Lights.""" + lights = [] + + for device, device_config in config[CONF_LIGHTS].items(): + friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) + state_template = device_config[CONF_VALUE_TEMPLATE] + on_action = device_config[CONF_ON_ACTION] + off_action = device_config[CONF_OFF_ACTION] + level_action = device_config[CONF_LEVEL_ACTION] + level_template = device_config[CONF_LEVEL_TEMPLATE] + + template_entity_ids = set() + + if state_template is not None: + temp_ids = state_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if level_template is not None: + temp_ids = level_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if not template_entity_ids: + template_entity_ids = MATCH_ALL + + entity_ids = device_config.get(CONF_ENTITY_ID, template_entity_ids) + + lights.append( + LightTemplate( + hass, device, friendly_name, state_template, + on_action, off_action, level_action, level_template, + entity_ids) + ) + + if not lights: + _LOGGER.error("No lights added") + return False + + async_add_devices(lights, True) + return True + + +class LightTemplate(Light): + """Representation of a templated Light, including dimmable.""" + + def __init__(self, hass, device_id, friendly_name, state_template, + on_action, off_action, level_action, level_template, + entity_ids): + """Initialize the light.""" + self.hass = hass + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, device_id, hass=hass) + self._name = friendly_name + self._template = state_template + self._on_script = Script(hass, on_action) + self._off_script = Script(hass, off_action) + self._level_script = Script(hass, level_action) + self._level_template = level_template + + self._state = False + self._brightness = None + self._entities = entity_ids + + if self._template is not None: + self._template.hass = self.hass + if self._level_template is not None: + self._level_template.hass = self.hass + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + if self._level_script is not None: + return SUPPORT_BRIGHTNESS + + return 0 + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + state = yield from async_get_last_state(self.hass, self.entity_id) + if state: + self._state = state.state == STATE_ON + + @callback + def template_light_state_listener(entity, old_state, new_state): + """Handle target device state changes.""" + self.hass.async_add_job(self.async_update_ha_state(True)) + + @callback + def template_light_startup(event): + """Update template on startup.""" + if (self._template is not None or + self._level_template is not None): + async_track_state_change( + self.hass, self._entities, template_light_state_listener) + + self.hass.async_add_job(self.async_update_ha_state(True)) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_light_startup) + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + optimistic_set = False + # set optimistic states + if self._template is None: + self._state = True + optimistic_set = True + + if self._level_template is None and ATTR_BRIGHTNESS in kwargs: + _LOGGER.info("Optimistically setting brightness to %s", + kwargs[ATTR_BRIGHTNESS]) + self._brightness = kwargs[ATTR_BRIGHTNESS] + optimistic_set = True + + if ATTR_BRIGHTNESS in kwargs and self._level_script: + self.hass.async_add_job(self._level_script.async_run( + {"brightness": kwargs[ATTR_BRIGHTNESS]})) + else: + self.hass.async_add_job(self._on_script.async_run()) + + if optimistic_set: + self.hass.async_add_job(self.async_update_ha_state()) + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + self.hass.async_add_job(self._off_script.async_run()) + if self._template is None: + self._state = False + self.hass.async_add_job(self.async_update_ha_state()) + + @asyncio.coroutine + def async_update(self): + """Update the state from the template.""" + if self._template is not None: + try: + state = self._template.async_render().lower() + except TemplateError as ex: + _LOGGER.error(ex) + self._state = None + + if state in _VALID_STATES: + self._state = state in ('true', STATE_ON) + else: + _LOGGER.error( + 'Received invalid light is_on state: %s. ' + + 'Expected: %s', + state, ', '.join(_VALID_STATES)) + self._state = None + + if self._level_template is not None: + try: + brightness = self._level_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + self._state = None + + if 0 <= int(brightness) <= 255: + self._brightness = brightness + else: + _LOGGER.error( + 'Received invalid brightness : %s' + + 'Expected: 0-255', + brightness) + self._brightness = None diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py new file mode 100644 index 00000000000..4abee754547 --- /dev/null +++ b/tests/components/light/test_template.py @@ -0,0 +1,637 @@ +"""The tests for the Template light platform.""" +import logging +import asyncio + +from homeassistant.core import callback, State, CoreState +from homeassistant import setup +import homeassistant.components as core +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE + +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_component) +_LOGGER = logging.getLogger(__name__) + + +class TestTemplateLight: + """Test the Template light.""" + + hass = None + calls = None + # pylint: disable=invalid-name + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.calls = [] + + @callback + def record_call(service): + """Track function calls..""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_template_state_text(self): + """"Test the state text of a template.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': + "{{ states.light.test_state.state }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.set('light.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_ON + + state = self.hass.states.set('light.test_state', STATE_OFF) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + def test_template_state_boolean_on(self): + """Test the setting of the state with boolean on.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{ 1 == 1 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_ON + + def test_template_state_boolean_off(self): + """Test the setting of the state with off.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{ 1 == 2 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + def test_template_syntax_error(self): + """Test templating syntax error.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{%- if false -%}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_invalid_name_does_not_create(self): + """Test invalid name.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'bad name here': { + 'value_template': "{{ 1== 1}}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_invalid_light_does_not_create(self): + """Test invalid light.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'switches': { + 'test_template_light': 'Invalid' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_no_lights_does_not_create(self): + """Test if there are no lights no creation.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template' + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_template_does_create(self): + """Test missing template.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'light_one': { + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() != [] + + def test_missing_on_does_not_create(self): + """Test missing on.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'bad name here': { + 'value_template': "{{ 1== 1}}", + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_off_does_not_create(self): + """Test missing off.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'bad name here': { + 'value_template': "{{ 1== 1}}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_on_action(self): + """Test on action.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{states.light.test_state.state}}", + 'turn_on': { + 'service': 'test.automation', + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('light.test_state', STATE_OFF) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + core.light.turn_on(self.hass, 'light.test_template_light') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_on_action_optimistic(self): + """Test on action with optimistic state.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'turn_on': { + 'service': 'test.automation', + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('light.test_state', STATE_OFF) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + core.light.turn_on(self.hass, 'light.test_template_light') + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert len(self.calls) == 1 + assert state.state == STATE_ON + + def test_off_action(self): + """Test off action.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{states.light.test_state.state}}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'test.automation', + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('light.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_ON + + core.light.turn_off(self.hass, 'light.test_template_light') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_off_action_optimistic(self): + """Test off action with optimistic state.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'test.automation', + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + core.light.turn_off(self.hass, 'light.test_template_light') + self.hass.block_till_done() + + assert len(self.calls) == 1 + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + def test_level_action_no_template(self): + """Test setting brightness with optimistic template.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': '{{1 == 1}}', + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'test.automation', + 'data_template': { + 'entity_id': 'test.test_state', + 'brightness': '{{brightness}}' + } + }, + } + } + } + }) + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.attributes.get('brightness') is None + + core.light.turn_on( + self.hass, 'light.test_template_light', **{ATTR_BRIGHTNESS: 124}) + self.hass.block_till_done() + assert len(self.calls) == 1 + assert self.calls[0].data['brightness'] == '124' + + state = self.hass.states.get('light.test_template_light') + _LOGGER.info(str(state.attributes)) + assert state is not None + assert state.attributes.get('brightness') == 124 + + def test_level_template(self): + """Test the template for the level.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{ 1 == 1 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + }, + 'level_template': + '{{42}}' + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state is not None + + assert state.attributes.get('brightness') == '42' + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + hass.data[DATA_RESTORE_CACHE] = { + 'light.test_template_light': + State('light.test_template_light', 'on'), + } + + hass.state = CoreState.starting + mock_component(hass, 'recorder') + yield from setup.async_setup_component(hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': + "{{states.light.test_state.state}}", + 'turn_on': { + 'service': 'test.automation', + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'test.automation', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + state = hass.states.get('light.test_template_light') + assert state.state == 'on' + + yield from hass.async_start() + yield from hass.async_block_till_done() + + state = hass.states.get('light.test_template_light') + assert state.state == 'off' From e7d783ca2a1cd2eeb5679b567db56b06cd379b8d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 May 2017 14:47:22 -0700 Subject: [PATCH 045/105] Update links.html --- docs/source/_templates/links.html | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/source/_templates/links.html b/docs/source/_templates/links.html index 57a2e09f99e..272809d1920 100644 --- a/docs/source/_templates/links.html +++ b/docs/source/_templates/links.html @@ -1,8 +1,6 @@ -
From 775d45ae5a12dd56055f583a20401a6ca638eeb4 Mon Sep 17 00:00:00 2001 From: amigian74 Date: Thu, 25 May 2017 00:23:52 +0200 Subject: [PATCH 046/105] Exclude filter for event types (#7627) * add exclude filter for event types to recorder component * corrected long line (279) * change source code structure add test for exclude event types * code cleanup * change source code structure * Update __init__.py * Update test_init.py --- homeassistant/components/recorder/__init__.py | 7 +++++++ tests/components/recorder/test_init.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 90dc34c4634..997c79f6975 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -44,6 +44,7 @@ DEFAULT_DB_FILE = 'home-assistant_v2.db' CONF_DB_URL = 'db_url' CONF_PURGE_DAYS = 'purge_days' +CONF_EVENT_TYPES = 'event_types' CONNECT_RETRY_WAIT = 3 @@ -51,6 +52,8 @@ FILTER_SCHEMA = vol.Schema({ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EVENT_TYPES, default=[]): vol.All(cv.ensure_list, [cv.string]) }), vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ @@ -142,6 +145,7 @@ class Recorder(threading.Thread): self.include_d = include.get(CONF_DOMAINS, []) self.exclude = exclude.get(CONF_ENTITIES, []) + \ exclude.get(CONF_DOMAINS, []) + self.exclude_t = exclude.get(CONF_EVENT_TYPES, []) self.get_session = None @@ -245,6 +249,9 @@ class Recorder(threading.Thread): elif event.event_type == EVENT_TIME_CHANGED: self.queue.task_done() continue + elif event.event_type in self.exclude_t: + self.queue.task_done() + continue entity_id = event.data.get(ATTR_ENTITY_ID) if entity_id is not None: diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index c43caefb67c..539b80f50d0 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -114,6 +114,18 @@ def _add_entities(hass, entity_ids): return [st.to_native() for st in session.query(States)] +def _add_events(hass, events): + with session_scope(hass=hass) as session: + session.query(Events).delete(synchronize_session=False) + for event_type in events: + hass.bus.fire(event_type) + hass.block_till_done() + hass.data[DATA_INSTANCE].block_till_done() + + with session_scope(hass=hass) as session: + return [ev.to_native() for ev in session.query(Events)] + + # pylint: disable=redefined-outer-name,invalid-name def test_saving_state_include_domains(hass_recorder): """Test saving and restoring a state.""" @@ -131,6 +143,14 @@ def test_saving_state_incl_entities(hass_recorder): assert hass.states.get('test2.recorder') == states[0] +def test_saving_event_exclude_event_type(hass_recorder): + """Test saving and restoring an event.""" + hass = hass_recorder({'exclude': {'event_types': 'test'}}) + events = _add_events(hass, ['test', 'test2']) + assert len(events) == 1 + assert events[0].event_type == 'test2' + + def test_saving_state_exclude_domains(hass_recorder): """Test saving and restoring a state.""" hass = hass_recorder({'exclude': {'domains': 'test'}}) From 0abde3aa57d5293090370a256df0ab5f19d8bcdf Mon Sep 17 00:00:00 2001 From: Hugo Herter Date: Wed, 24 May 2017 15:31:51 -0700 Subject: [PATCH 047/105] Change setup script to use pip install instead of setup.py develop (#7756) Using `python setup.py develop` did not manage to install the required dependencies. This updates `script/setup` to use `pip install -e .` instead in order to resolve the required dependencies. --- script/setup | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/setup b/script/setup index 3dbfc1d2c97..f554efe9153 100755 --- a/script/setup +++ b/script/setup @@ -7,4 +7,5 @@ set -e cd "$(dirname "$0")/.." git submodule init script/bootstrap -python3 setup.py develop + +pip3 install -e . From 3a843e1817ec5ea7ffed1bd3da8681ca73884533 Mon Sep 17 00:00:00 2001 From: Anton Sarukhanov Date: Wed, 24 May 2017 19:12:26 -0700 Subject: [PATCH 048/105] Add icons to device tracker. (#7759) --- homeassistant/components/device_tracker/__init__.py | 11 +++++++++-- tests/components/device_tracker/test_init.py | 9 ++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 8770aaafaa8..acf402a0c8a 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -35,7 +35,8 @@ from homeassistant.util.yaml import dump from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_MAC, - DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_ID) + DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_ID, + CONF_ICON, ATTR_ICON) _LOGGER = logging.getLogger(__name__) @@ -381,6 +382,7 @@ class Device(Entity): battery = None # type: str attributes = None # type: dict vendor = None # type: str + icon = None # type: str # Track if the last update of this device was HOME. last_update_home = False @@ -388,7 +390,7 @@ class Device(Entity): def __init__(self, hass: HomeAssistantType, consider_home: timedelta, track: bool, dev_id: str, mac: str, name: str=None, - picture: str=None, gravatar: str=None, + picture: str=None, gravatar: str=None, icon: str=None, hide_if_away: bool=False, vendor: str=None) -> None: """Initialize a device.""" self.hass = hass @@ -414,6 +416,8 @@ class Device(Entity): else: self.config_picture = picture + self.icon = icon + self.away_hide = hide_if_away self.vendor = vendor @@ -637,6 +641,8 @@ def async_load_config(path: str, hass: HomeAssistantType, """ dev_schema = vol.Schema({ vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON, default=False): + vol.Any(None, cv.icon), vol.Optional('track', default=False): cv.boolean, vol.Optional(CONF_MAC, default=None): vol.Any(None, vol.All(cv.string, vol.Upper)), @@ -728,6 +734,7 @@ def update_config(path: str, dev_id: str, device: Device): device = {device.dev_id: { ATTR_NAME: device.name, ATTR_MAC: device.mac, + ATTR_ICON: device.icon, 'picture': device.config_picture, 'track': device.track, CONF_AWAY_HIDE: device.away_hide, diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index d4f301c6fc5..cd4ea0f686f 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -17,7 +17,7 @@ from homeassistant.util.async import run_coroutine_threadsafe import homeassistant.util.dt as dt_util from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, - STATE_HOME, STATE_NOT_HOME, CONF_PLATFORM) + STATE_HOME, STATE_NOT_HOME, CONF_PLATFORM, ATTR_ICON) import homeassistant.components.device_tracker as device_tracker from homeassistant.exceptions import HomeAssistantError from homeassistant.remote import JSONEncoder @@ -98,7 +98,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): device = device_tracker.Device( self.hass, timedelta(seconds=180), True, dev_id, 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', - hide_if_away=True) + hide_if_away=True, icon='mdi:kettle') device_tracker.update_config(self.yaml_devices, dev_id, device) with assert_setup_component(1, device_tracker.DOMAIN): assert setup_component(self.hass, device_tracker.DOMAIN, @@ -112,6 +112,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): self.assertEqual(device.away_hide, config.away_hide) self.assertEqual(device.consider_home, config.consider_home) self.assertEqual(device.vendor, config.vendor) + self.assertEqual(device.icon, config.icon) # pylint: disable=invalid-name @patch('homeassistant.components.device_tracker._LOGGER.warning') @@ -377,10 +378,11 @@ class TestComponentsDeviceTracker(unittest.TestCase): entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) friendly_name = 'Paulus' picture = 'http://placehold.it/200x200' + icon = 'mdi:kettle' device = device_tracker.Device( self.hass, timedelta(seconds=180), True, dev_id, None, - friendly_name, picture, hide_if_away=True) + friendly_name, picture, hide_if_away=True, icon=icon) device_tracker.update_config(self.yaml_devices, dev_id, device) with assert_setup_component(1, device_tracker.DOMAIN): @@ -390,6 +392,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): attrs = self.hass.states.get(entity_id).attributes self.assertEqual(friendly_name, attrs.get(ATTR_FRIENDLY_NAME)) + self.assertEqual(icon, attrs.get(ATTR_ICON)) self.assertEqual(picture, attrs.get(ATTR_ENTITY_PICTURE)) def test_device_hidden(self): From 65c3201fa694f29e0a379fae32c8bd7f2d1bace1 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 26 May 2017 00:11:02 -0400 Subject: [PATCH 049/105] Rename of the zwave hass.data constants (#7768) * Rename of the zwave hass.data constants * Remove zwave since it is already implied --- homeassistant/components/cover/zwave.py | 2 +- homeassistant/components/lock/zwave.py | 2 +- homeassistant/components/zwave/__init__.py | 14 ++++----- homeassistant/components/zwave/api.py | 8 ++--- homeassistant/components/zwave/const.py | 3 ++ tests/components/cover/test_zwave.py | 10 +++---- tests/components/lock/test_zwave.py | 6 ++-- tests/components/zwave/test_api.py | 34 +++++++++++----------- tests/components/zwave/test_init.py | 16 +++++----- 9 files changed, 47 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 781d7a03280..b682bee3e20 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -41,7 +41,7 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): """Initialize the Z-Wave rollershutter.""" ZWaveDeviceEntity.__init__(self, values, DOMAIN) # pylint: disable=no-member - self._network = hass.data[zwave.ZWAVE_NETWORK] + self._network = hass.data[zwave.const.DATA_NETWORK] self._open_id = None self._close_id = None self._current_position = None diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 7654d354a31..e9199290e30 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -128,7 +128,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): descriptions = load_yaml_config_file( path.join(path.dirname(__file__), 'services.yaml')) - network = hass.data[zwave.ZWAVE_NETWORK] + network = hass.data[zwave.const.DATA_NETWORK] def set_usercode(service): """Set the usercode to index X on the lock.""" diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 4b86c191763..640db9d4939 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -30,7 +30,7 @@ from homeassistant.components.frontend import register_built_in_panel from . import api from . import const -from .const import DOMAIN +from .const import DOMAIN, DATA_DEVICES, DATA_NETWORK from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity from . import workaround from .discovery_schemas import DISCOVERY_SCHEMAS @@ -67,10 +67,8 @@ DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS = False 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({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), @@ -210,10 +208,10 @@ def get_config_value(node, value_index, tries=5): @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Z-Wave platform (generic part).""" - if discovery_info is None or ZWAVE_NETWORK not in hass.data: + if discovery_info is None or DATA_NETWORK not in hass.data: return False - device = hass.data[DATA_ZWAVE_DICT].pop( + device = hass.data[DATA_DEVICES].pop( discovery_info[const.DISCOVERY_DEVICE], None) if device is None: return False @@ -258,8 +256,8 @@ def setup(hass, config): options.lock() - network = hass.data[ZWAVE_NETWORK] = ZWaveNetwork(options, autostart=False) - hass.data[DATA_ZWAVE_DICT] = {} + network = hass.data[DATA_NETWORK] = ZWaveNetwork(options, autostart=False) + hass.data[DATA_DEVICES] = {} if use_debug: # pragma: no cover def log_all(signal, value=None): @@ -783,7 +781,7 @@ class ZWaveDeviceEntityValues(): @asyncio.coroutine def discover_device(component, device, dict_id): """Put device in a dictionary and call discovery on it.""" - self._hass.data[DATA_ZWAVE_DICT][dict_id] = device + self._hass.data[DATA_DEVICES][dict_id] = device yield from discovery.async_load_platform( self._hass, component, DOMAIN, {const.DISCOVERY_DEVICE: dict_id}, self._zwave_config) diff --git a/homeassistant/components/zwave/api.py b/homeassistant/components/zwave/api.py index 9e3066f91c5..1cf3b38b26f 100644 --- a/homeassistant/components/zwave/api.py +++ b/homeassistant/components/zwave/api.py @@ -8,8 +8,6 @@ from . import const _LOGGER = logging.getLogger(__name__) -ZWAVE_NETWORK = 'zwave_network' - class ZWaveNodeGroupView(HomeAssistantView): """View to return the nodes group configuration.""" @@ -22,7 +20,7 @@ class ZWaveNodeGroupView(HomeAssistantView): """Retrieve groups of node.""" nodeid = int(node_id) hass = request.app['hass'] - network = hass.data.get(ZWAVE_NETWORK) + network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: return self.json_message('Node not found', HTTP_NOT_FOUND) @@ -48,7 +46,7 @@ class ZWaveNodeConfigView(HomeAssistantView): """Retrieve configurations of node.""" nodeid = int(node_id) hass = request.app['hass'] - network = hass.data.get(ZWAVE_NETWORK) + network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: return self.json_message('Node not found', HTTP_NOT_FOUND) @@ -77,7 +75,7 @@ class ZWaveUserCodeView(HomeAssistantView): """Retrieve usercodes of node.""" nodeid = int(node_id) hass = request.app['hass'] - network = hass.data.get(ZWAVE_NETWORK) + network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: return self.json_message('Node not found', HTTP_NOT_FOUND) diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index b2fcf448db5..dd88dccaa80 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -18,6 +18,9 @@ NETWORK_READY_WAIT_SECS = 30 DISCOVERY_DEVICE = 'device' +DATA_DEVICES = 'zwave_devices' +DATA_NETWORK = 'zwave_network' + SERVICE_CHANGE_ASSOCIATION = "change_association" SERVICE_ADD_NODE = "add_node" SERVICE_ADD_NODE_SECURE = "add_node_secure" diff --git a/tests/components/cover/test_zwave.py b/tests/components/cover/test_zwave.py index aebc04c2d4c..e1ee6075cea 100644 --- a/tests/components/cover/test_zwave.py +++ b/tests/components/cover/test_zwave.py @@ -21,7 +21,7 @@ def test_get_device_detects_none(hass, mock_openzwave): def test_get_device_detects_rollershutter(hass, mock_openzwave): """Test device returns rollershutter.""" - hass.data[zwave.zwave.ZWAVE_NETWORK] = MagicMock() + hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode() value = MockValue(data=0, node=node, command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) @@ -48,7 +48,7 @@ def test_get_device_detects_garagedoor(hass, mock_openzwave): def test_roller_no_position_workaround(hass, mock_openzwave): """Test position changed.""" - hass.data[zwave.zwave.ZWAVE_NETWORK] = MagicMock() + hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(manufacturer_id='0047', product_type='5a52') value = MockValue(data=45, node=node, command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) @@ -61,7 +61,7 @@ def test_roller_no_position_workaround(hass, mock_openzwave): def test_roller_value_changed(hass, mock_openzwave): """Test position changed.""" - hass.data[zwave.zwave.ZWAVE_NETWORK] = MagicMock() + hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode() value = MockValue(data=None, node=node, command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) @@ -93,7 +93,7 @@ def test_roller_value_changed(hass, mock_openzwave): def test_roller_commands(hass, mock_openzwave): """Test position changed.""" - mock_network = hass.data[zwave.zwave.ZWAVE_NETWORK] = MagicMock() + mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode() value = MockValue(data=50, node=node, command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) @@ -128,7 +128,7 @@ def test_roller_commands(hass, mock_openzwave): def test_roller_reverse_open_close(hass, mock_openzwave): """Test position changed.""" - mock_network = hass.data[zwave.zwave.ZWAVE_NETWORK] = MagicMock() + mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode() value = MockValue(data=50, node=node, command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index b0e9456b8a8..83aec7f0ce9 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -171,7 +171,7 @@ def test_lock_alarm_level(mock_openzwave): @asyncio.coroutine 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() + mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) value0 = MockValue(data=' ', node=node, index=0) value1 = MockValue(data=' ', node=node, index=1) @@ -213,7 +213,7 @@ def test_lock_set_usercode_service(hass, mock_openzwave): @asyncio.coroutine def test_lock_get_usercode_service(hass, mock_openzwave): """Test the zwave lock get_usercode service.""" - mock_network = hass.data[zwave.zwave.ZWAVE_NETWORK] = MagicMock() + mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) value0 = MockValue(data=None, node=node, index=0) value1 = MockValue(data='1234', node=node, index=1) @@ -242,7 +242,7 @@ def test_lock_get_usercode_service(hass, mock_openzwave): @asyncio.coroutine def test_lock_clear_usercode_service(hass, mock_openzwave): """Test the zwave lock clear_usercode service.""" - mock_network = hass.data[zwave.zwave.ZWAVE_NETWORK] = MagicMock() + mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) value0 = MockValue(data=None, node=node, index=0) value1 = MockValue(data='123', node=node, index=1) diff --git a/tests/components/zwave/test_api.py b/tests/components/zwave/test_api.py index aabfd39024c..c1b2022c5e1 100644 --- a/tests/components/zwave/test_api.py +++ b/tests/components/zwave/test_api.py @@ -1,7 +1,7 @@ """Test Z-Wave config panel.""" import asyncio from unittest.mock import MagicMock -from homeassistant.components.zwave import ZWAVE_NETWORK, const +from homeassistant.components.zwave import DATA_NETWORK, const from homeassistant.components.zwave.api import ( ZWaveNodeGroupView, ZWaveNodeConfigView, ZWaveUserCodeView) from tests.common import mock_http_component_app @@ -14,7 +14,7 @@ def test_get_groups(hass, test_client): app = mock_http_component_app(hass) ZWaveNodeGroupView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) node.groups.associations = 'assoc' node.groups.associations_instances = 'inst' @@ -46,7 +46,7 @@ def test_get_groups_nogroups(hass, test_client): app = mock_http_component_app(hass) ZWaveNodeGroupView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) network.nodes = {2: node} @@ -67,7 +67,7 @@ def test_get_groups_nonode(hass, test_client): app = mock_http_component_app(hass) ZWaveNodeGroupView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} client = yield from test_client(app) @@ -86,11 +86,11 @@ def test_get_config(hass, test_client): app = mock_http_component_app(hass) ZWaveNodeConfigView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) value = MockValue( - index=12, - command_class=const.COMMAND_CLASS_CONFIGURATION) + index=12, + command_class=const.COMMAND_CLASS_CONFIGURATION) value.label = 'label' value.help = 'help' value.type = 'type' @@ -124,7 +124,7 @@ def test_get_config_noconfig_node(hass, test_client): app = mock_http_component_app(hass) ZWaveNodeConfigView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) network.nodes = {2: node} @@ -146,7 +146,7 @@ def test_get_config_nonode(hass, test_client): app = mock_http_component_app(hass) ZWaveNodeConfigView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} client = yield from test_client(app) @@ -165,7 +165,7 @@ def test_get_usercodes_nonode(hass, test_client): app = mock_http_component_app(hass) ZWaveUserCodeView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} client = yield from test_client(app) @@ -184,12 +184,12 @@ def test_get_usercodes(hass, test_client): app = mock_http_component_app(hass) ZWaveUserCodeView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_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) + index=0, + command_class=const.COMMAND_CLASS_USER_CODE) value.genre = const.GENRE_USER value.label = 'label' value.data = '1234' @@ -215,7 +215,7 @@ def test_get_usercode_nousercode_node(hass, test_client): app = mock_http_component_app(hass) ZWaveUserCodeView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=18) network.nodes = {18: node} @@ -237,12 +237,12 @@ def test_get_usercodes_no_genreuser(hass, test_client): app = mock_http_component_app(hass) ZWaveUserCodeView().register(app.router) - network = hass.data[ZWAVE_NETWORK] = MagicMock() + network = hass.data[DATA_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) + index=0, + command_class=const.COMMAND_CLASS_USER_CODE) value.genre = const.GENRE_SYSTEM value.label = 'label' value.data = '1234' diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index de500a81893..49ec1ea6a95 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -8,7 +8,7 @@ from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START from homeassistant.components import zwave from homeassistant.components.binary_sensor.zwave import get_device from homeassistant.components.zwave import ( - const, CONFIG_SCHEMA, CONF_DEVICE_CONFIG_GLOB, ZWAVE_NETWORK) + const, CONFIG_SCHEMA, CONF_DEVICE_CONFIG_GLOB, DATA_NETWORK) from homeassistant.setup import setup_component import pytest @@ -82,7 +82,7 @@ def test_network_options(hass, mock_openzwave): assert result - network = hass.data[zwave.ZWAVE_NETWORK] + network = hass.data[zwave.DATA_NETWORK] assert network.options.device == 'mock_usb_path' assert network.options.config_path == 'mock_config_path' @@ -94,7 +94,7 @@ def test_auto_heal_midnight(hass, mock_openzwave): 'zwave': { 'autoheal': True, }})) - network = hass.data[zwave.ZWAVE_NETWORK] + network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called time = datetime(2017, 5, 6, 0, 0, 0) @@ -111,7 +111,7 @@ def test_auto_heal_disabled(hass, mock_openzwave): 'zwave': { 'autoheal': False, }})) - network = hass.data[zwave.ZWAVE_NETWORK] + network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called time = datetime(2017, 5, 6, 0, 0, 0) @@ -139,8 +139,8 @@ def test_frontend_panel_register(hass, mock_openzwave): def test_setup_platform(hass, mock_openzwave): """Test invalid device config.""" mock_device = MagicMock() - hass.data[ZWAVE_NETWORK] = MagicMock() - hass.data[zwave.DATA_ZWAVE_DICT] = {456: mock_device} + hass.data[DATA_NETWORK] = MagicMock() + hass.data[zwave.DATA_DEVICES] = {456: mock_device} async_add_devices = MagicMock() result = yield from zwave.async_setup_platform( @@ -170,7 +170,7 @@ def test_zwave_ready_wait(hass, mock_openzwave): with patch.object(zwave.time, 'sleep') as mock_sleep: with patch.object(zwave, '_LOGGER') as mock_logger: - hass.data[ZWAVE_NETWORK].state = MockNetwork.STATE_STARTED + hass.data[DATA_NETWORK].state = MockNetwork.STATE_STARTED hass.bus.async_fire(EVENT_HOMEASSISTANT_START) yield from hass.async_block_till_done() @@ -772,7 +772,7 @@ class TestZWaveServices(unittest.TestCase): # Initialize zwave setup_component(self.hass, 'zwave', {'zwave': {}}) self.hass.block_till_done() - self.zwave_network = self.hass.data[ZWAVE_NETWORK] + self.zwave_network = self.hass.data[DATA_NETWORK] self.zwave_network.state = MockNetwork.STATE_READY self.hass.bus.fire(EVENT_HOMEASSISTANT_START) self.hass.block_till_done() From 81aaeaaf113aceecc44bd248517a94b6593fab3f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 25 May 2017 21:13:53 -0700 Subject: [PATCH 050/105] Get rid of mock http component app (#7775) * Remove mock_http_component from config tests * Remove mock_http_component_app from emulated hue test --- tests/components/config/test_core.py | 8 ++--- tests/components/config/test_group.py | 31 +++--------------- tests/components/config/test_hassbian.py | 32 +++++++------------ tests/components/config/test_zwave.py | 31 +++--------------- tests/components/emulated_hue/test_hue_api.py | 5 ++- 5 files changed, 26 insertions(+), 81 deletions(-) diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index b9c2a1739c5..4d82d695f8b 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -4,22 +4,18 @@ from unittest.mock import patch from homeassistant.bootstrap import async_setup_component from homeassistant.components import config -from homeassistant.components.config.core import CheckConfigView -from tests.common import mock_http_component_app, mock_coro +from tests.common import mock_coro @asyncio.coroutine def test_validate_config_ok(hass, test_client): """Test checking config.""" - app = mock_http_component_app(hass) with patch.object(config, 'SECTIONS', ['core']): yield from async_setup_component(hass, 'config', {}) - # yield from hass.async_block_till_done() yield from asyncio.sleep(0.1, loop=hass.loop) - hass.http.views[CheckConfigView.name].register(app.router) - client = yield from test_client(app) + client = yield from test_client(hass.http.app) with patch( 'homeassistant.components.config.core.async_check_ha_config_file', diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py index 223b556dce3..6cc6d67811e 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -5,7 +5,6 @@ from unittest.mock import patch from homeassistant.bootstrap import async_setup_component from homeassistant.components import config -from tests.common import mock_http_component_app VIEW_NAME = 'api:config:group:config' @@ -14,14 +13,10 @@ VIEW_NAME = 'api:config:group:config' @asyncio.coroutine def test_get_device_config(hass, test_client): """Test getting device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) def mock_read(path): """Mock reading data.""" @@ -47,14 +42,10 @@ def test_get_device_config(hass, test_client): @asyncio.coroutine def test_update_device_config(hass, test_client): """Test updating device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) orig_data = { 'hello.beer': { @@ -96,14 +87,10 @@ def test_update_device_config(hass, test_client): @asyncio.coroutine def test_update_device_config_invalid_key(hass, test_client): """Test updating device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/config/group/config/not a slug', data=json.dumps({ @@ -116,14 +103,10 @@ def test_update_device_config_invalid_key(hass, test_client): @asyncio.coroutine def test_update_device_config_invalid_data(hass, test_client): """Test updating device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/config/group/config/hello_beer', data=json.dumps({ @@ -136,14 +119,10 @@ def test_update_device_config_invalid_data(hass, test_client): @asyncio.coroutine def test_update_device_config_invalid_json(hass, test_client): """Test updating device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/config/group/config/hello_beer', data='not json') diff --git a/tests/components/config/test_hassbian.py b/tests/components/config/test_hassbian.py index b30ba6b71a6..659e5ad2448 100644 --- a/tests/components/config/test_hassbian.py +++ b/tests/components/config/test_hassbian.py @@ -7,44 +7,40 @@ from homeassistant.bootstrap import async_setup_component from homeassistant.components import config from homeassistant.components.config.hassbian import ( HassbianSuitesView, HassbianSuiteInstallView) -from tests.common import ( - mock_http_component, mock_http_component_app) def test_setup_check_env_prevents_load(hass, loop): """Test it does not set up hassbian if environment var not present.""" - mock_http_component(hass) with patch.dict(os.environ, clear=True), \ - patch.object(config, 'SECTIONS', ['hassbian']): + patch.object(config, 'SECTIONS', ['hassbian']), \ + patch('homeassistant.components.http.' + 'HomeAssistantWSGI.register_view') as reg_view: loop.run_until_complete(async_setup_component(hass, 'config', {})) assert 'config' in hass.config.components - assert HassbianSuitesView.name not in hass.http.views - assert HassbianSuiteInstallView.name not in hass.http.views + assert reg_view.called is False def test_setup_check_env_works(hass, loop): """Test it sets up hassbian if environment var present.""" - mock_http_component(hass) with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ - patch.object(config, 'SECTIONS', ['hassbian']): + patch.object(config, 'SECTIONS', ['hassbian']), \ + patch('homeassistant.components.http.' + 'HomeAssistantWSGI.register_view') as reg_view: loop.run_until_complete(async_setup_component(hass, 'config', {})) assert 'config' in hass.config.components - assert HassbianSuitesView.name in hass.http.views - assert HassbianSuiteInstallView.name in hass.http.views + assert len(reg_view.mock_calls) == 2 + assert isinstance(reg_view.mock_calls[0][1][0], HassbianSuitesView) + assert isinstance(reg_view.mock_calls[1][1][0], HassbianSuiteInstallView) @asyncio.coroutine def test_get_suites(hass, test_client): """Test getting suites.""" - app = mock_http_component_app(hass) - with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[HassbianSuitesView.name].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) resp = yield from client.get('/api/config/hassbian/suites') assert resp.status == 200 result = yield from resp.json() @@ -59,15 +55,11 @@ def test_get_suites(hass, test_client): @asyncio.coroutine def test_install_suite(hass, test_client): """Test getting suites.""" - app = mock_http_component_app(hass) - with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[HassbianSuiteInstallView.name].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/config/hassbian/suites/openzwave/install') assert resp.status == 200 diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index 0a136653070..6e4e35df64b 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -5,7 +5,6 @@ from unittest.mock import patch from homeassistant.bootstrap import async_setup_component from homeassistant.components import config -from tests.common import mock_http_component_app VIEW_NAME = 'api:config:zwave:device_config' @@ -14,14 +13,10 @@ VIEW_NAME = 'api:config:zwave:device_config' @asyncio.coroutine def test_get_device_config(hass, test_client): """Test getting device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['zwave']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) def mock_read(path): """Mock reading data.""" @@ -47,14 +42,10 @@ def test_get_device_config(hass, test_client): @asyncio.coroutine def test_update_device_config(hass, test_client): """Test updating device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['zwave']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) orig_data = { 'hello.beer': { @@ -94,14 +85,10 @@ def test_update_device_config(hass, test_client): @asyncio.coroutine def test_update_device_config_invalid_key(hass, test_client): """Test updating device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['zwave']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/config/zwave/device_config/invalid_entity', data=json.dumps({ @@ -114,14 +101,10 @@ def test_update_device_config_invalid_key(hass, test_client): @asyncio.coroutine def test_update_device_config_invalid_data(hass, test_client): """Test updating device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['zwave']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/config/zwave/device_config/hello.beer', data=json.dumps({ @@ -134,14 +117,10 @@ def test_update_device_config_invalid_data(hass, test_client): @asyncio.coroutine def test_update_device_config_invalid_json(hass, test_client): """Test updating device config.""" - app = mock_http_component_app(hass) - with patch.object(config, 'SECTIONS', ['zwave']): yield from async_setup_component(hass, 'config', {}) - hass.http.views[VIEW_NAME].register(app.router) - - client = yield from test_client(app) + client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/config/zwave/device_config/hello.beer', data='not json') diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index a6f1b71ee75..0d2f0d24da0 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -16,8 +16,7 @@ from homeassistant.components.emulated_hue.hue_api import ( HueAllLightsStateView, HueOneLightStateView, HueOneLightChangeView) from homeassistant.components.emulated_hue import Config -from tests.common import ( - get_test_instance_port, mock_http_component_app) +from tests.common import get_test_instance_port HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() @@ -114,7 +113,7 @@ def hass_hue(loop, hass): @pytest.fixture def hue_client(loop, hass_hue, test_client): """Create web client for emulated hue api.""" - web_app = mock_http_component_app(hass_hue) + web_app = hass_hue.http.app config = Config(None, {'type': 'alexa'}) HueUsernameView().register(web_app.router) From d0c9d6b69adeba0ea0223e8cd592cc2a5fee2879 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 25 May 2017 21:40:36 -0700 Subject: [PATCH 051/105] Remove usage of event_loop fixture (#7776) --- tests/helpers/test_state.py | 46 +++++++++++++++---------------------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index 3ef9bd1b03b..e9d163ad471 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -22,40 +22,30 @@ from homeassistant.components.sun import (STATE_ABOVE_HORIZON, from tests.common import get_test_home_assistant, mock_service -def test_async_track_states(event_loop): +@asyncio.coroutine +def test_async_track_states(hass): """Test AsyncTrackStates context manager.""" - hass = get_test_home_assistant() + point1 = dt_util.utcnow() + point2 = point1 + timedelta(seconds=5) + point3 = point2 + timedelta(seconds=5) - try: - point1 = dt_util.utcnow() - point2 = point1 + timedelta(seconds=5) - point3 = point2 + timedelta(seconds=5) + with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: + mock_utcnow.return_value = point2 + + with state.AsyncTrackStates(hass) as states: + mock_utcnow.return_value = point1 + hass.states.async_set('light.test', 'on') - @asyncio.coroutine - @patch('homeassistant.core.dt_util.utcnow') - def run_test(mock_utcnow): - """Run the test.""" mock_utcnow.return_value = point2 + hass.states.async_set('light.test2', 'on') + state2 = hass.states.get('light.test2') - with state.AsyncTrackStates(hass) as states: - mock_utcnow.return_value = point1 - hass.states.set('light.test', 'on') + mock_utcnow.return_value = point3 + hass.states.async_set('light.test3', 'on') + state3 = hass.states.get('light.test3') - mock_utcnow.return_value = point2 - hass.states.set('light.test2', 'on') - state2 = hass.states.get('light.test2') - - mock_utcnow.return_value = point3 - hass.states.set('light.test3', 'on') - state3 = hass.states.get('light.test3') - - assert [state2, state3] == \ - sorted(states, key=lambda state: state.entity_id) - - event_loop.run_until_complete(run_test()) - - finally: - hass.stop() + assert [state2, state3] == \ + sorted(states, key=lambda state: state.entity_id) class TestStateHelpers(unittest.TestCase): From 6899c7b6f74493acf57a769ffbf9a807005e3d4e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 25 May 2017 22:21:22 -0700 Subject: [PATCH 052/105] assertEquals is deprecated (#7777) --- tests/components/binary_sensor/test_aurora.py | 2 +- tests/components/calendar/test_google.py | 36 +++++++++---------- .../components/device_tracker/test_tplink.py | 2 +- tests/components/test_google.py | 4 +-- tests/scripts/test_init.py | 2 +- tests/util/test_dt.py | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/components/binary_sensor/test_aurora.py b/tests/components/binary_sensor/test_aurora.py index 57ce47f553c..c18d07575ca 100644 --- a/tests/components/binary_sensor/test_aurora.py +++ b/tests/components/binary_sensor/test_aurora.py @@ -91,5 +91,5 @@ class TestAuroraSensorSetUp(unittest.TestCase): aurora.setup_platform(self.hass, config, mock_add_entities) aurora_component = entities[0] - self.assertEquals(aurora_component.aurora_data.visibility_level, '5') + self.assertEqual(aurora_component.aurora_data.visibility_level, '5') self.assertTrue(aurora_component.is_on) diff --git a/tests/components/calendar/test_google.py b/tests/components/calendar/test_google.py index 7496b4519ab..1de825efd99 100755 --- a/tests/components/calendar/test_google.py +++ b/tests/components/calendar/test_google.py @@ -86,13 +86,13 @@ class TestComponentsGoogleCalendar(unittest.TestCase): cal = calendar.GoogleCalendarEventDevice(self.hass, None, '', {'name': device_name}) - self.assertEquals(cal.name, device_name) + self.assertEqual(cal.name, device_name) - self.assertEquals(cal.state, STATE_OFF) + self.assertEqual(cal.state, STATE_OFF) self.assertFalse(cal.offset_reached()) - self.assertEquals(cal.device_state_attributes, { + self.assertEqual(cal.device_state_attributes, { 'message': event['summary'], 'all_day': True, 'offset_reached': False, @@ -145,13 +145,13 @@ class TestComponentsGoogleCalendar(unittest.TestCase): cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, {'name': device_name}) - self.assertEquals(cal.name, device_name) + self.assertEqual(cal.name, device_name) - self.assertEquals(cal.state, STATE_OFF) + self.assertEqual(cal.state, STATE_OFF) self.assertFalse(cal.offset_reached()) - self.assertEquals(cal.device_state_attributes, { + self.assertEqual(cal.device_state_attributes, { 'message': event['summary'], 'all_day': False, 'offset_reached': False, @@ -207,13 +207,13 @@ class TestComponentsGoogleCalendar(unittest.TestCase): cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, {'name': device_name}) - self.assertEquals(cal.name, device_name) + self.assertEqual(cal.name, device_name) - self.assertEquals(cal.state, STATE_ON) + self.assertEqual(cal.state, STATE_ON) self.assertFalse(cal.offset_reached()) - self.assertEquals(cal.device_state_attributes, { + self.assertEqual(cal.device_state_attributes, { 'message': event['summary'], 'all_day': False, 'offset_reached': False, @@ -270,13 +270,13 @@ class TestComponentsGoogleCalendar(unittest.TestCase): cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, {'name': device_name}) - self.assertEquals(cal.name, device_name) + self.assertEqual(cal.name, device_name) - self.assertEquals(cal.state, STATE_OFF) + self.assertEqual(cal.state, STATE_OFF) self.assertTrue(cal.offset_reached()) - self.assertEquals(cal.device_state_attributes, { + self.assertEqual(cal.device_state_attributes, { 'message': event_summary, 'all_day': False, 'offset_reached': True, @@ -339,13 +339,13 @@ class TestComponentsGoogleCalendar(unittest.TestCase): cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, {'name': device_name}) - self.assertEquals(cal.name, device_name) + self.assertEqual(cal.name, device_name) - self.assertEquals(cal.state, STATE_OFF) + self.assertEqual(cal.state, STATE_OFF) self.assertTrue(cal.offset_reached()) - self.assertEquals(cal.device_state_attributes, { + self.assertEqual(cal.device_state_attributes, { 'message': event_summary, 'all_day': True, 'offset_reached': True, @@ -406,13 +406,13 @@ class TestComponentsGoogleCalendar(unittest.TestCase): cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, {'name': device_name}) - self.assertEquals(cal.name, device_name) + self.assertEqual(cal.name, device_name) - self.assertEquals(cal.state, STATE_OFF) + self.assertEqual(cal.state, STATE_OFF) self.assertFalse(cal.offset_reached()) - self.assertEquals(cal.device_state_attributes, { + self.assertEqual(cal.device_state_attributes, { 'message': event_summary, 'all_day': True, 'offset_reached': False, diff --git a/tests/components/device_tracker/test_tplink.py b/tests/components/device_tracker/test_tplink.py index 171548358db..88e38108133 100644 --- a/tests/components/device_tracker/test_tplink.py +++ b/tests/components/device_tracker/test_tplink.py @@ -65,4 +65,4 @@ class TestTplink4DeviceScanner(unittest.TestCase): expected_mac_results = [mac.replace('-', ':') for mac in [FAKE_MAC_1, FAKE_MAC_2, FAKE_MAC_3]] - self.assertEquals(tplink.last_results, expected_mac_results) + self.assertEqual(tplink.last_results, expected_mac_results) diff --git a/tests/components/test_google.py b/tests/components/test_google.py index 004a6e0edaf..fd45cfc59a9 100644 --- a/tests/components/test_google.py +++ b/tests/components/test_google.py @@ -52,7 +52,7 @@ class TestGoogle(unittest.TestCase): } calendar_info = google.get_calendar_info(self.hass, calendar) - self.assertEquals(calendar_info, { + self.assertEqual(calendar_info, { 'cal_id': 'qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com', 'entities': [{ 'device_id': 'we_are_we_are_a_test_calendar', @@ -80,7 +80,7 @@ class TestGoogle(unittest.TestCase): # } # self.assertIsInstance(self.hass.data[google.DATA_INDEX], dict) - # self.assertEquals(self.hass.data[google.DATA_INDEX], {}) + # self.assertEqual(self.hass.data[google.DATA_INDEX], {}) calendar_service = google.GoogleCalendarService( self.hass.config.path(google.TOKEN_FILE)) diff --git a/tests/scripts/test_init.py b/tests/scripts/test_init.py index 7a8a74c4b65..bfb98e90f24 100644 --- a/tests/scripts/test_init.py +++ b/tests/scripts/test_init.py @@ -12,7 +12,7 @@ class TestScripts(unittest.TestCase): return_value='/default') def test_config_per_platform(self, mock_def): """Test config per platform method.""" - self.assertEquals(scripts.get_default_config_dir(), '/default') + self.assertEqual(scripts.get_default_config_dir(), '/default') self.assertEqual(scripts.extract_config_dir(), '/default') self.assertEqual(scripts.extract_config_dir(['']), '/default') self.assertEqual(scripts.extract_config_dir(['-c', '/arg']), '/arg') diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index ab2e7dd5244..feee69ad3c8 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -117,7 +117,7 @@ class TestDateUtil(unittest.TestCase): # confirm the ability to handle a string passed in delta = dt_util.as_timestamp("2016-01-01 12:12:12") delta -= dt_util.as_timestamp("2016-01-01 12:12:11") - self.assertEquals(1, delta) + self.assertEqual(1, delta) def test_parse_datetime_converts_correctly(self): """Test parse_datetime converts strings.""" From 9e9705d6b27dff7158be4a42b606bc343cb27448 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 26 May 2017 01:55:00 -0400 Subject: [PATCH 053/105] Support for GE Zwave fan controller (#7767) * Support for GE Zwave fan controller * Tests for zwave fan * Add additional fan workarounds --- homeassistant/components/fan/zwave.py | 86 ++++++++++++++ homeassistant/components/zwave/workaround.py | 42 ++++++- tests/components/fan/test_zwave.py | 117 +++++++++++++++++++ tests/components/light/test_zwave.py | 2 +- tests/components/zwave/test_workaround.py | 17 +++ 5 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/fan/zwave.py create mode 100644 tests/components/fan/test_zwave.py diff --git a/homeassistant/components/fan/zwave.py b/homeassistant/components/fan/zwave.py new file mode 100644 index 00000000000..fe01ae5f3a4 --- /dev/null +++ b/homeassistant/components/fan/zwave.py @@ -0,0 +1,86 @@ +""" +Z-Wave platform that handles fans. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.zwave/ +""" +import logging +import math + +from homeassistant.components.fan import ( + DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + SUPPORT_SET_SPEED) +from homeassistant.components import zwave +from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +SPEED_LIST = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + +SUPPORTED_FEATURES = SUPPORT_SET_SPEED + +# Value will first be divided to an integer +VALUE_TO_SPEED = { + 0: SPEED_OFF, + 1: SPEED_LOW, + 2: SPEED_MEDIUM, + 3: SPEED_HIGH, +} + +SPEED_TO_VALUE = { + SPEED_OFF: 0, + SPEED_LOW: 1, + SPEED_MEDIUM: 50, + SPEED_HIGH: 99, +} + + +def get_device(values, **kwargs): + """Create zwave entity device.""" + return ZwaveFan(values) + + +class ZwaveFan(zwave.ZWaveDeviceEntity, FanEntity): + """Representation of a Z-Wave fan.""" + + def __init__(self, values): + """Initialize the Z-Wave fan device.""" + zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + self.update_properties() + + def update_properties(self): + """Handle data changes for node values.""" + value = math.ceil(self.values.primary.data * 3 / 100) + self._state = VALUE_TO_SPEED[value] + + def set_speed(self, speed): + """Set the speed of the fan.""" + self.node.set_dimmer( + self.values.primary.value_id, SPEED_TO_VALUE[speed]) + + def turn_on(self, speed=None, **kwargs): + """Turn the device on.""" + if speed is None: + # Value 255 tells device to return to previous value + self.node.set_dimmer(self.values.primary.value_id, 255) + else: + self.set_speed(speed) + + def turn_off(self, **kwargs): + """Turn the device off.""" + self.node.set_dimmer(self.values.primary.value_id, 0) + + @property + def speed(self): + """Return the current speed.""" + return self._state + + @property + def speed_list(self): + """Get the list of available speeds.""" + return SPEED_LIST + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES diff --git a/homeassistant/components/zwave/workaround.py b/homeassistant/components/zwave/workaround.py index 9d2433b7118..0a882a093c6 100644 --- a/homeassistant/components/zwave/workaround.py +++ b/homeassistant/components/zwave/workaround.py @@ -3,22 +3,30 @@ from . import const # Manufacturers FIBARO = 0x010f +GE = 0x0063 PHILIO = 0x013c +SOMFY = 0x0047 WENZHOU = 0x0118 -SOMFY = 0x47 +VIZIA = 0x001D # Product IDs +GE_FAN_CONTROLLER_12730 = 0x3034 +GE_FAN_CONTROLLER_14287 = 0x3131 +JASCO_FAN_CONTROLLER_14314 = 0x3138 PHILIO_SLIM_SENSOR = 0x0002 PHILIO_3_IN_1_SENSOR_GEN_4 = 0x000d PHILIO_PAN07 = 0x0005 +VIZIA_FAN_CONTROLLER_VRF01 = 0x0334 # Product Types FGFS101_FLOOD_SENSOR_TYPE = 0x0b00 FGRM222_SHUTTER2 = 0x0301 FGR222_SHUTTER2 = 0x0302 +GE_DIMMER = 0x4944 PHILIO_SWITCH = 0x0001 PHILIO_SENSOR = 0x0002 SOMFY_ZRTSI = 0x5a52 +VIZIA_DIMMER = 0x1001 # Mapping devices PHILIO_SLIM_SENSOR_MOTION_MTII = (PHILIO, PHILIO_SENSOR, PHILIO_SLIM_SENSOR, 0) @@ -53,7 +61,6 @@ DEVICE_MAPPINGS_MT = { SOMFY_ZRTSI_CONTROLLER_MT: WORKAROUND_NO_POSITION, } - # Component mapping devices FIBARO_FGFS101_SENSOR_ALARM = ( FIBARO, FGFS101_FLOOD_SENSOR_TYPE, const.COMMAND_CLASS_SENSOR_ALARM) @@ -61,6 +68,18 @@ FIBARO_FGRM222_BINARY = ( FIBARO, FGRM222_SHUTTER2, const.COMMAND_CLASS_SWITCH_BINARY) FIBARO_FGR222_BINARY = ( FIBARO, FGR222_SHUTTER2, const.COMMAND_CLASS_SWITCH_BINARY) +GE_FAN_CONTROLLER_12730_MULTILEVEL = ( + GE, GE_DIMMER, GE_FAN_CONTROLLER_12730, + const.COMMAND_CLASS_SWITCH_MULTILEVEL) +GE_FAN_CONTROLLER_14287_MULTILEVEL = ( + GE, GE_DIMMER, GE_FAN_CONTROLLER_14287, + const.COMMAND_CLASS_SWITCH_MULTILEVEL) +JASCO_FAN_CONTROLLER_14314_MULTILEVEL = ( + GE, GE_DIMMER, JASCO_FAN_CONTROLLER_14314, + const.COMMAND_CLASS_SWITCH_MULTILEVEL) +VIZIA_FAN_CONTROLLER_VRF01_MULTILEVEL = ( + VIZIA, VIZIA_DIMMER, VIZIA_FAN_CONTROLLER_VRF01, + const.COMMAND_CLASS_SWITCH_MULTILEVEL) # List of component workarounds by # (manufacturer_id, product_type, command_class) @@ -70,6 +89,15 @@ DEVICE_COMPONENT_MAPPING = { FIBARO_FGR222_BINARY: WORKAROUND_IGNORE, } +# List of component workarounds by +# (manufacturer_id, product_type, product_id, command_class) +DEVICE_COMPONENT_MAPPING_MTI = { + GE_FAN_CONTROLLER_12730_MULTILEVEL: 'fan', + GE_FAN_CONTROLLER_14287_MULTILEVEL: 'fan', + JASCO_FAN_CONTROLLER_14314_MULTILEVEL: 'fan', + VIZIA_FAN_CONTROLLER_VRF01_MULTILEVEL: 'fan', +} + def get_device_component_mapping(value): """Get mapping of value to another component.""" @@ -77,8 +105,16 @@ def get_device_component_mapping(value): value.node.product_type.strip()): manufacturer_id = int(value.node.manufacturer_id, 16) product_type = int(value.node.product_type, 16) - return DEVICE_COMPONENT_MAPPING.get( + product_id = int(value.node.product_id, 16) + result = DEVICE_COMPONENT_MAPPING.get( (manufacturer_id, product_type, value.command_class)) + if result: + return result + + result = DEVICE_COMPONENT_MAPPING_MTI.get( + (manufacturer_id, product_type, product_id, value.command_class)) + if result: + return result return None diff --git a/tests/components/fan/test_zwave.py b/tests/components/fan/test_zwave.py new file mode 100644 index 00000000000..b7d7e497c03 --- /dev/null +++ b/tests/components/fan/test_zwave.py @@ -0,0 +1,117 @@ +"""Test Z-Wave fans.""" +from homeassistant.components.fan import ( + zwave, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) + +from tests.mock.zwave import ( + MockNode, MockValue, MockEntityValues, value_changed) + + +def test_get_device_detects_fan(mock_openzwave): + """Test get_device returns a zwave fan.""" + node = MockNode() + value = MockValue(data=0, node=node) + values = MockEntityValues(primary=value) + + device = zwave.get_device(node=node, values=values, node_config={}) + assert isinstance(device, zwave.ZwaveFan) + assert device.supported_features == SUPPORT_SET_SPEED + assert device.speed_list == [ + SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + +def test_fan_turn_on(mock_openzwave): + """Test turning on a zwave fan.""" + node = MockNode() + value = MockValue(data=0, node=node) + values = MockEntityValues(primary=value) + device = zwave.get_device(node=node, values=values, node_config={}) + + device.turn_on() + + assert node.set_dimmer.called + value_id, brightness = node.set_dimmer.mock_calls[0][1] + assert value_id == value.value_id + assert brightness == 255 + + node.reset_mock() + + device.turn_on(speed=SPEED_OFF) + + assert node.set_dimmer.called + value_id, brightness = node.set_dimmer.mock_calls[0][1] + + assert value_id == value.value_id + assert brightness == 0 + + node.reset_mock() + + device.turn_on(speed=SPEED_LOW) + + assert node.set_dimmer.called + value_id, brightness = node.set_dimmer.mock_calls[0][1] + + assert value_id == value.value_id + assert brightness == 1 + + node.reset_mock() + + device.turn_on(speed=SPEED_MEDIUM) + + assert node.set_dimmer.called + value_id, brightness = node.set_dimmer.mock_calls[0][1] + + assert value_id == value.value_id + assert brightness == 50 + + node.reset_mock() + + device.turn_on(speed=SPEED_HIGH) + + assert node.set_dimmer.called + value_id, brightness = node.set_dimmer.mock_calls[0][1] + + assert value_id == value.value_id + assert brightness == 99 + + +def test_fan_turn_off(mock_openzwave): + """Test turning off a dimmable zwave fan.""" + node = MockNode() + value = MockValue(data=46, node=node) + values = MockEntityValues(primary=value) + device = zwave.get_device(node=node, values=values, node_config={}) + + device.turn_off() + + assert node.set_dimmer.called + value_id, brightness = node.set_dimmer.mock_calls[0][1] + assert value_id == value.value_id + assert brightness == 0 + + +def test_fan_value_changed(mock_openzwave): + """Test value changed for zwave fan.""" + node = MockNode() + value = MockValue(data=0, node=node) + values = MockEntityValues(primary=value) + device = zwave.get_device(node=node, values=values, node_config={}) + + assert not device.is_on + + value.data = 10 + value_changed(value) + + assert device.is_on + assert device.speed == SPEED_LOW + + value.data = 50 + value_changed(value) + + assert device.is_on + assert device.speed == SPEED_MEDIUM + + value.data = 90 + value_changed(value) + + assert device.is_on + assert device.speed == SPEED_HIGH diff --git a/tests/components/light/test_zwave.py b/tests/components/light/test_zwave.py index 9629744bc16..a260d160bb5 100644 --- a/tests/components/light/test_zwave.py +++ b/tests/components/light/test_zwave.py @@ -24,7 +24,7 @@ class MockLightValues(MockEntityValues): def test_get_device_detects_dimmer(mock_openzwave): - """Test get_device returns a color light.""" + """Test get_device returns a normal dimmer.""" node = MockNode() value = MockValue(data=0, node=node) values = MockLightValues(primary=value) diff --git a/tests/components/zwave/test_workaround.py b/tests/components/zwave/test_workaround.py index de901ad4dc1..ebc21692e85 100644 --- a/tests/components/zwave/test_workaround.py +++ b/tests/components/zwave/test_workaround.py @@ -18,6 +18,23 @@ def test_get_device_component_mapping(): assert workaround.get_device_component_mapping(value) == 'binary_sensor' +def test_get_device_component_mapping_mti(): + """Test that component is returned.""" + # GE Fan controller + node = MockNode(manufacturer_id='0063', product_type='4944', + product_id='3034') + value = MockValue(data=0, node=node, + command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) + assert workaround.get_device_component_mapping(value) == 'fan' + + # GE Dimmer + node = MockNode(manufacturer_id='0063', product_type='4944', + product_id='3031') + value = MockValue(data=0, node=node, + command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) + assert workaround.get_device_component_mapping(value) is None + + def test_get_device_no_mapping(): """Test that no device mapping is returned.""" node = MockNode(manufacturer_id=' ') From f43db3c615db00c578b03014136e5ddd2289e830 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 26 May 2017 08:28:07 -0700 Subject: [PATCH 054/105] Replace executor with async_add_job (#7658) * Remove executor * Lint * Lint * Fix tests --- homeassistant/bootstrap.py | 12 ++-- .../alarm_control_panel/__init__.py | 16 ++---- .../alarm_control_panel/envisalink.py | 4 +- homeassistant/components/alert.py | 4 +- .../components/automation/__init__.py | 4 +- homeassistant/components/camera/__init__.py | 2 +- homeassistant/components/camera/generic.py | 4 +- homeassistant/components/camera/mjpeg.py | 4 +- homeassistant/components/climate/__init__.py | 35 +++++------- homeassistant/components/cover/__init__.py | 33 +++++------ .../components/device_tracker/gpslogger.py | 10 ++-- .../components/device_tracker/locative.py | 14 ++--- homeassistant/components/discovery.py | 3 +- homeassistant/components/eight_sleep.py | 4 +- homeassistant/components/fan/__init__.py | 16 +++--- homeassistant/components/ffmpeg.py | 4 +- homeassistant/components/frontend/__init__.py | 4 +- homeassistant/components/group.py | 4 +- homeassistant/components/history.py | 6 +- homeassistant/components/http/ban.py | 9 ++- .../components/image_processing/__init__.py | 6 +- homeassistant/components/light/__init__.py | 7 +-- homeassistant/components/lock/__init__.py | 10 ++-- homeassistant/components/logbook.py | 4 +- homeassistant/components/logger.py | 4 +- .../components/media_player/__init__.py | 55 +++++++------------ homeassistant/components/media_player/kodi.py | 4 +- homeassistant/components/microsoft_face.py | 4 +- homeassistant/components/mqtt/__init__.py | 22 ++++---- homeassistant/components/notify/__init__.py | 12 ++-- .../components/persistent_notification.py | 4 +- homeassistant/components/remote/__init__.py | 7 +-- homeassistant/components/remote/kira.py | 3 +- homeassistant/components/rflink.py | 2 +- homeassistant/components/scene/__init__.py | 2 +- homeassistant/components/switch/__init__.py | 4 +- homeassistant/components/switch/broadlink.py | 18 +++--- .../components/telegram_bot/__init__.py | 4 +- homeassistant/components/tts/__init__.py | 26 ++++----- homeassistant/components/tts/google.py | 4 +- homeassistant/config.py | 10 ++-- homeassistant/helpers/entity.py | 11 ++-- homeassistant/helpers/entity_component.py | 6 +- homeassistant/helpers/restore_state.py | 4 +- homeassistant/setup.py | 6 +- tests/helpers/test_entity.py | 13 ++--- 46 files changed, 196 insertions(+), 248 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index eeda1db51fc..5f64fd447a6 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -83,8 +83,7 @@ def async_from_config_dict(config: Dict[str, Any], conf_util.async_log_exception(ex, 'homeassistant', core_config, hass) return None - yield from hass.loop.run_in_executor( - None, conf_util.process_ha_config_upgrade, hass) + yield from hass.async_add_job(conf_util.process_ha_config_upgrade, hass) if enable_log: async_enable_logging(hass, verbose, log_rotate_days) @@ -95,7 +94,7 @@ def async_from_config_dict(config: Dict[str, Any], 'This may cause issues.') if not loader.PREPARED: - yield from hass.loop.run_in_executor(None, loader.prepare, hass) + yield from hass.async_add_job(loader.prepare, hass) # Merge packages conf_util.merge_packages_config( @@ -184,14 +183,13 @@ def async_from_config_file(config_path: str, # Set config dir to directory holding config file config_dir = os.path.abspath(os.path.dirname(config_path)) hass.config.config_dir = config_dir - yield from hass.loop.run_in_executor( - None, mount_local_lib_path, config_dir) + yield from hass.async_add_job(mount_local_lib_path, config_dir) async_enable_logging(hass, verbose, log_rotate_days) try: - config_dict = yield from hass.loop.run_in_executor( - None, conf_util.load_yaml_config_file, config_path) + config_dict = yield from hass.async_add_job( + conf_util.load_yaml_config_file, config_path) except HomeAssistantError as err: _LOGGER.error('Error loading %s: %s', config_path, err) return None diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index a13abfae8f9..80c5e0ad1cc 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -123,8 +123,8 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) for service in SERVICE_TO_METHOD: @@ -158,8 +158,7 @@ class AlarmControlPanel(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.alarm_disarm, code) + return self.hass.async_add_job(self.alarm_disarm, code) def alarm_arm_home(self, code=None): """Send arm home command.""" @@ -170,8 +169,7 @@ class AlarmControlPanel(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.alarm_arm_home, code) + return self.hass.async_add_job(self.alarm_arm_home, code) def alarm_arm_away(self, code=None): """Send arm away command.""" @@ -182,8 +180,7 @@ class AlarmControlPanel(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.alarm_arm_away, code) + return self.hass.async_add_job(self.alarm_arm_away, code) def alarm_trigger(self, code=None): """Send alarm trigger command.""" @@ -194,8 +191,7 @@ class AlarmControlPanel(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.alarm_trigger, code) + return self.hass.async_add_job(self.alarm_trigger, code) @property def state_attributes(self): diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index 34919a9db79..6029816ba76 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -70,8 +70,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device.async_alarm_keypress(keypress) # Register Envisalink specific services - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index 24c14e7c9a8..09db0f84346 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -128,8 +128,8 @@ def async_setup(hass, config): all_alerts[entity.entity_id] = entity # Read descriptions - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) descriptions = descriptions.get(DOMAIN, {}) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 9227222d479..a99113b6f6f 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -158,8 +158,8 @@ def async_setup(hass, config): 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( + descriptions = yield from hass.async_add_job( + conf_util.load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml') ) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 79f0757d006..d33dc996afd 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -138,7 +138,7 @@ class Camera(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor(None, self.camera_image) + return self.hass.async_add_job(self.camera_image) @asyncio.coroutine def handle_async_mjpeg_stream(self, request): diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index 83164c55230..8a9854ab97e 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -103,8 +103,8 @@ class GenericCamera(Camera): _LOGGER.error("Error getting camera image: %s", error) return self._last_image - self._last_image = yield from self.hass.loop.run_in_executor( - None, fetch) + self._last_image = yield from self.hass.async_add_job( + fetch) # async else: try: diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 1e9859fe7c2..6168eb81939 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -88,8 +88,8 @@ class MjpegCamera(Camera): # DigestAuth is not supported if self._authentication == HTTP_DIGEST_AUTHENTICATION or \ self._still_image_url is None: - image = yield from self.hass.loop.run_in_executor( - None, self.camera_image) + image = yield from self.hass.async_add_job( + self.camera_image) return image websession = async_get_clientsession(self.hass) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 2e2dfbef8ca..f9405e4b040 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -213,8 +213,8 @@ def async_setup(hass, config): component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) yield from component.async_setup(config) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) @asyncio.coroutine @@ -569,8 +569,8 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.set_temperature, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.set_temperature, **kwargs)) def set_humidity(self, humidity): """Set new target humidity.""" @@ -581,8 +581,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.set_humidity, humidity) + return self.hass.async_add_job(self.set_humidity, humidity) def set_fan_mode(self, fan): """Set new target fan mode.""" @@ -593,8 +592,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.set_fan_mode, fan) + return self.hass.async_add_job(self.set_fan_mode, fan) def set_operation_mode(self, operation_mode): """Set new target operation mode.""" @@ -605,8 +603,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.set_operation_mode, operation_mode) + return self.hass.async_add_job(self.set_operation_mode, operation_mode) def set_swing_mode(self, swing_mode): """Set new target swing operation.""" @@ -617,8 +614,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.set_swing_mode, swing_mode) + return self.hass.async_add_job(self.set_swing_mode, swing_mode) def turn_away_mode_on(self): """Turn away mode on.""" @@ -629,8 +625,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.turn_away_mode_on) + return self.hass.async_add_job(self.turn_away_mode_on) def turn_away_mode_off(self): """Turn away mode off.""" @@ -641,8 +636,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.turn_away_mode_off) + return self.hass.async_add_job(self.turn_away_mode_off) def set_hold_mode(self, hold_mode): """Set new target hold mode.""" @@ -653,8 +647,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.set_hold_mode, hold_mode) + return self.hass.async_add_job(self.set_hold_mode, hold_mode) def turn_aux_heat_on(self): """Turn auxillary heater on.""" @@ -665,8 +658,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.turn_aux_heat_on) + return self.hass.async_add_job(self.turn_aux_heat_on) def turn_aux_heat_off(self): """Turn auxillary heater off.""" @@ -677,8 +669,7 @@ class ClimateDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.turn_aux_heat_off) + return self.hass.async_add_job(self.turn_aux_heat_off) @property def min_temp(self): diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index bbee0e836a3..f913d126c4a 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -175,8 +175,8 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) for service_name in SERVICE_TO_METHOD: @@ -263,8 +263,7 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.open_cover, **kwargs)) + return self.hass.async_add_job(ft.partial(self.open_cover, **kwargs)) def close_cover(self, **kwargs): """Close cover.""" @@ -275,8 +274,7 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.close_cover, **kwargs)) + return self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -287,8 +285,8 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.set_cover_position, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.set_cover_position, **kwargs)) def stop_cover(self, **kwargs): """Stop the cover.""" @@ -299,8 +297,7 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.stop_cover, **kwargs)) + return self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs)) def open_cover_tilt(self, **kwargs): """Open the cover tilt.""" @@ -311,8 +308,8 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.open_cover_tilt, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.open_cover_tilt, **kwargs)) def close_cover_tilt(self, **kwargs): """Close the cover tilt.""" @@ -323,8 +320,8 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.close_cover_tilt, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.close_cover_tilt, **kwargs)) def set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" @@ -335,8 +332,8 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.set_cover_tilt_position, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.set_cover_tilt_position, **kwargs)) def stop_cover_tilt(self, **kwargs): """Stop the cover.""" @@ -347,5 +344,5 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.stop_cover_tilt, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.stop_cover_tilt, **kwargs)) diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index 733127cb0f2..81961b13f7f 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -75,10 +75,10 @@ class GPSLoggerView(HomeAssistantView): if 'activity' in data: attrs['activity'] = data['activity'] - yield from hass.loop.run_in_executor( - None, partial(self.see, dev_id=device, - gps=gps_location, battery=battery, - gps_accuracy=accuracy, - attributes=attrs)) + yield from hass.async_add_job( + partial(self.see, dev_id=device, + gps=gps_location, battery=battery, + gps_accuracy=accuracy, + attributes=attrs)) return 'Setting location for {}'.format(device) diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py index 668ee6dd8a0..b41326c4192 100644 --- a/homeassistant/components/device_tracker/locative.py +++ b/homeassistant/components/device_tracker/locative.py @@ -79,10 +79,9 @@ class LocativeView(HomeAssistantView): gps_location = (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]) if direction == 'enter': - yield from hass.loop.run_in_executor( - None, partial(self.see, dev_id=device, - location_name=location_name, - gps=gps_location)) + yield from hass.async_add_job( + partial(self.see, dev_id=device, location_name=location_name, + gps=gps_location)) return 'Setting location to {}'.format(location_name) elif direction == 'exit': @@ -91,10 +90,9 @@ class LocativeView(HomeAssistantView): if current_state is None or current_state.state == location_name: location_name = STATE_NOT_HOME - yield from hass.loop.run_in_executor( - None, partial(self.see, dev_id=device, - location_name=location_name, - gps=gps_location)) + yield from hass.async_add_job( + partial(self.see, dev_id=device, + location_name=location_name, gps=gps_location)) return 'Setting location to not home' else: # Ignore the message if it is telling us to exit a zone that we diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 261d8953940..a068deb076c 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -115,8 +115,7 @@ def async_setup(hass, config): @asyncio.coroutine def scan_devices(now): """Scan for devices.""" - results = yield from hass.loop.run_in_executor( - None, _discover, netdisco) + results = yield from hass.async_add_job(_discover, netdisco) for result in results: hass.async_add_job(new_service_found(*result)) diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 3a2929f4bd1..db8af964fed 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -159,8 +159,8 @@ def async_setup(hass, config): CONF_BINARY_SENSORS: binary_sensors, }, config)) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) @asyncio.coroutine diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 500cebfe73b..54c503c1b9f 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -229,8 +229,8 @@ def async_setup(hass, config: dict): yield from asyncio.wait(update_tasks, loop=hass.loop) # Listen for fan service calls. - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) for service_name in SERVICE_TO_METHOD: @@ -256,7 +256,7 @@ class FanEntity(ToggleEntity): """ if speed is SPEED_OFF: return self.async_turn_off() - return self.hass.loop.run_in_executor(None, self.set_speed, speed) + return self.hass.async_add_job(self.set_speed, speed) def set_direction(self: ToggleEntity, direction: str) -> None: """Set the direction of the fan.""" @@ -267,8 +267,7 @@ class FanEntity(ToggleEntity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.set_direction, direction) + return self.hass.async_add_job(self.set_direction, direction) def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: """Turn on the fan.""" @@ -281,8 +280,8 @@ class FanEntity(ToggleEntity): """ if speed is SPEED_OFF: return self.async_turn_off() - return self.hass.loop.run_in_executor( - None, ft.partial(self.turn_on, speed, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.turn_on, speed, **kwargs)) def oscillate(self: ToggleEntity, oscillating: bool) -> None: """Oscillate the fan.""" @@ -293,8 +292,7 @@ class FanEntity(ToggleEntity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.oscillate, oscillating) + return self.hass.async_add_job(self.oscillate, oscillating) @property def is_on(self): diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 959962f02ac..45bd651ad95 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -89,8 +89,8 @@ def async_setup(hass, config): conf.get(CONF_RUN_TEST, DEFAULT_RUN_TEST) ) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) # Register service diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 617db06be2c..8d55ad879fa 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -268,8 +268,8 @@ class IndexView(HomeAssistantView): no_auth = 'true' icons_url = '/static/mdi-{}.html'.format(FINGERPRINTS['mdi.html']) - template = yield from hass.loop.run_in_executor( - None, self.templates.get_template, 'index.html') + template = yield from hass.async_add_job( + self.templates.get_template, 'index.html') # pylint is wrong # pylint: disable=no-member diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 2e97ef9ef3b..f207a9b8b62 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -173,8 +173,8 @@ def async_setup(hass, config): 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( + descriptions = yield from hass.async_add_job( + conf_util.load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml') ) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 70c61589bb3..5a960ae894b 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -233,9 +233,9 @@ class HistoryPeriodView(HomeAssistantView): end_time = start_time + one_day entity_id = request.GET.get('filter_entity_id') - result = yield from request.app['hass'].loop.run_in_executor( - None, get_significant_states, request.app['hass'], start_time, - end_time, entity_id, self.filters) + result = yield from request.app['hass'].async_add_job( + get_significant_states, request.app['hass'], start_time, end_time, + entity_id, self.filters) result = result.values() if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 53635af9fc8..d2b9fa402ef 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -40,8 +40,8 @@ def ban_middleware(app, handler): if KEY_BANNED_IPS not in app: hass = app['hass'] - app[KEY_BANNED_IPS] = yield from hass.loop.run_in_executor( - None, load_ip_bans_config, hass.config.path(IP_BANS_FILE)) + app[KEY_BANNED_IPS] = yield from hass.async_add_job( + load_ip_bans_config, hass.config.path(IP_BANS_FILE)) @asyncio.coroutine def ban_middleware_handler(request): @@ -90,9 +90,8 @@ def process_wrong_login(request): request.app[KEY_BANNED_IPS].append(new_ban) hass = request.app['hass'] - yield from hass.loop.run_in_executor( - None, update_ip_bans_config, hass.config.path(IP_BANS_FILE), - new_ban) + yield from hass.async_add_job( + update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban) _LOGGER.warning( "Banned IP %s for too many login attempts", remote_addr) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 7ca8b48931b..fb1cddcad61 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -72,8 +72,8 @@ def async_setup(hass, config): yield from component.async_setup(config) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) @asyncio.coroutine @@ -117,7 +117,7 @@ class ImageProcessingEntity(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor(None, self.process_image, image) + return self.hass.async_add_job(self.process_image, image) @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 92db75d1e50..2065513630b 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -285,8 +285,8 @@ def async_setup(hass, config): yield from asyncio.wait(update_tasks, loop=hass.loop) # Listen for light on and light off service calls. - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( @@ -341,8 +341,7 @@ class Profiles: return None return profiles - cls._all = yield from hass.loop.run_in_executor( - None, load_profile_data, hass) + cls._all = yield from hass.async_add_job(load_profile_data, hass) return cls._all is not None @classmethod diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 7d3e75595e2..27af8d28764 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -108,8 +108,8 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( @@ -150,8 +150,7 @@ class LockDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.lock, **kwargs)) + return self.hass.async_add_job(ft.partial(self.lock, **kwargs)) def unlock(self, **kwargs): """Unlock the lock.""" @@ -162,8 +161,7 @@ class LockDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.unlock, **kwargs)) + return self.hass.async_add_job(ft.partial(self.unlock, **kwargs)) @property def state_attributes(self): diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 053648e3428..29c69409774 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -134,8 +134,8 @@ class LogbookView(HomeAssistantView): end_day = start_day + timedelta(days=1) hass = request.app['hass'] - events = yield from hass.loop.run_in_executor( - None, _get_events, hass, start_day, end_day) + events = yield from hass.async_add_job( + _get_events, hass, start_day, end_day) events = _exclude_events(events, self.config) return self.json(humanify(events)) diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py index 94a3ad902da..6b79bd40987 100644 --- a/homeassistant/components/logger.py +++ b/homeassistant/components/logger.py @@ -123,8 +123,8 @@ def async_setup(hass, config): """Handle logger services.""" set_log_levels(service.data) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 97712e1c0ad..47d018d0849 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -353,8 +353,8 @@ def async_setup(hass, config): yield from component.async_setup(config) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) @asyncio.coroutine @@ -583,8 +583,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.turn_on) + return self.hass.async_add_job(self.turn_on) def turn_off(self): """Turn the media player off.""" @@ -595,8 +594,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.turn_off) + return self.hass.async_add_job(self.turn_off) def mute_volume(self, mute): """Mute the volume.""" @@ -607,8 +605,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.mute_volume, mute) + return self.hass.async_add_job(self.mute_volume, mute) def set_volume_level(self, volume): """Set volume level, range 0..1.""" @@ -619,8 +616,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.set_volume_level, volume) + return self.hass.async_add_job(self.set_volume_level, volume) def media_play(self): """Send play commmand.""" @@ -631,8 +627,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.media_play) + return self.hass.async_add_job(self.media_play) def media_pause(self): """Send pause command.""" @@ -643,8 +638,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.media_pause) + return self.hass.async_add_job(self.media_pause) def media_stop(self): """Send stop command.""" @@ -655,8 +649,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.media_stop) + return self.hass.async_add_job(self.media_stop) def media_previous_track(self): """Send previous track command.""" @@ -667,8 +660,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.media_previous_track) + return self.hass.async_add_job(self.media_previous_track) def media_next_track(self): """Send next track command.""" @@ -679,8 +671,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.media_next_track) + return self.hass.async_add_job(self.media_next_track) def media_seek(self, position): """Send seek command.""" @@ -691,8 +682,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.media_seek, position) + return self.hass.async_add_job(self.media_seek, position) def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" @@ -703,8 +693,8 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.play_media, media_type, media_id, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.play_media, media_type, media_id, **kwargs)) def select_source(self, source): """Select input source.""" @@ -715,8 +705,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.select_source, source) + return self.hass.async_add_job(self.select_source, source) def clear_playlist(self): """Clear players playlist.""" @@ -727,8 +716,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.clear_playlist) + return self.hass.async_add_job(self.clear_playlist) def set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" @@ -739,8 +727,7 @@ class MediaPlayerDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, self.set_shuffle, shuffle) + return self.hass.async_add_job(self.set_shuffle, shuffle) # No need to overwrite these. @property @@ -810,7 +797,7 @@ class MediaPlayerDevice(Entity): """ if hasattr(self, 'toggle'): # pylint: disable=no-member - return self.hass.loop.run_in_executor(None, self.toggle) + return self.hass.async_add_job(self.toggle) if self.state in [STATE_OFF, STATE_IDLE]: return self.async_turn_on() @@ -825,7 +812,7 @@ class MediaPlayerDevice(Entity): """ if hasattr(self, 'volume_up'): # pylint: disable=no-member - yield from self.hass.loop.run_in_executor(None, self.volume_up) + yield from self.hass.async_add_job(self.volume_up) return if self.volume_level < 1: @@ -840,7 +827,7 @@ class MediaPlayerDevice(Entity): """ if hasattr(self, 'volume_down'): # pylint: disable=no-member - yield from self.hass.loop.run_in_executor(None, self.volume_down) + yield from self.hass.async_add_job(self.volume_down) return if self.volume_level > 0: @@ -854,7 +841,7 @@ class MediaPlayerDevice(Entity): """ if hasattr(self, 'media_play_pause'): # pylint: disable=no-member - return self.hass.loop.run_in_executor(None, self.media_play_pause) + return self.hass.async_add_job(self.media_play_pause) if self.state == STATE_PLAYING: return self.async_media_pause() diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 9861887df89..b7cc45b68f5 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -175,8 +175,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 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( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) for service in SERVICE_TO_METHOD: diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py index a2a52b68665..49d79ccaea0 100644 --- a/homeassistant/components/microsoft_face.py +++ b/homeassistant/components/microsoft_face.py @@ -133,8 +133,8 @@ def async_setup(hass, config): hass.data[DATA_MICROSOFT_FACE] = face - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) @asyncio.coroutine diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index b4701ad4690..f4b57f0c803 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -403,8 +403,8 @@ def async_setup(hass, config): yield from hass.data[DATA_MQTT].async_publish( msg_topic, payload, qos, retain) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( @@ -477,8 +477,8 @@ class MQTT(object): This method must be run in the event loop and returns a coroutine. """ with (yield from self._paho_lock): - yield from self.hass.loop.run_in_executor( - None, self._mqttc.publish, topic, payload, qos, retain) + yield from self.hass.async_add_job( + self._mqttc.publish, topic, payload, qos, retain) @asyncio.coroutine def async_connect(self): @@ -486,8 +486,8 @@ class MQTT(object): This method is a coroutine. """ - result = yield from self.hass.loop.run_in_executor( - None, self._mqttc.connect, self.broker, self.port, self.keepalive) + result = yield from self.hass.async_add_job( + self._mqttc.connect, self.broker, self.port, self.keepalive) if result != 0: import paho.mqtt.client as mqtt @@ -507,7 +507,7 @@ class MQTT(object): self._mqttc.disconnect() self._mqttc.loop_stop() - return self.hass.loop.run_in_executor(None, stop) + return self.hass.async_add_job(stop) @asyncio.coroutine def async_subscribe(self, topic, qos): @@ -522,8 +522,8 @@ class MQTT(object): if topic in self.topics: return - result, mid = yield from self.hass.loop.run_in_executor( - None, self._mqttc.subscribe, topic, qos) + result, mid = yield from self.hass.async_add_job( + self._mqttc.subscribe, topic, qos) _raise_on_error(result) self.progress[mid] = topic @@ -535,8 +535,8 @@ class MQTT(object): This method is a coroutine. """ - result, mid = yield from self.hass.loop.run_in_executor( - None, self._mqttc.unsubscribe, topic) + result, mid = yield from self.hass.async_add_job( + self._mqttc.unsubscribe, topic) _raise_on_error(result) self.progress[mid] = topic diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index cf05629ce1b..f9f9d04c05c 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -69,8 +69,8 @@ def send_message(hass, message, title=None, data=None): @asyncio.coroutine def async_setup(hass, config): """Set up the notify services.""" - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) targets = {} @@ -97,8 +97,8 @@ def async_setup(hass, config): notify_service = yield from \ platform.async_get_service(hass, p_config, discovery_info) elif hasattr(platform, 'get_service'): - notify_service = yield from hass.loop.run_in_executor( - None, platform.get_service, hass, p_config, discovery_info) + notify_service = yield from hass.async_add_job( + platform.get_service, hass, p_config, discovery_info) else: raise HomeAssistantError("Invalid notify platform.") @@ -192,5 +192,5 @@ class BaseNotificationService(object): kwargs can contain ATTR_TITLE to specify a title. This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, partial(self.send_message, message, **kwargs)) + return self.hass.async_add_job( + partial(self.send_message, message, **kwargs)) diff --git a/homeassistant/components/persistent_notification.py b/homeassistant/components/persistent_notification.py index 7173045c06d..5e36c471562 100644 --- a/homeassistant/components/persistent_notification.py +++ b/homeassistant/components/persistent_notification.py @@ -92,8 +92,8 @@ def async_setup(hass, config): hass.states.async_set(entity_id, message, attr) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml') ) hass.services.async_register(DOMAIN, SERVICE_CREATE, create_service, diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index a28ebd666f9..40a419eaa58 100755 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -140,8 +140,8 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_remote_service, @@ -171,5 +171,4 @@ class RemoteDevice(ToggleEntity): 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)) + return self.hass.async_add_job(ft.partial(self.send_command, **kwargs)) diff --git a/homeassistant/components/remote/kira.py b/homeassistant/components/remote/kira.py index 7ab73068cdb..6f167bfdd05 100755 --- a/homeassistant/components/remote/kira.py +++ b/homeassistant/components/remote/kira.py @@ -75,5 +75,4 @@ class KiraRemote(Entity): 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)) + return self.hass.async_add_job(ft.partial(self.send_command, **kwargs)) diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index 33feb8c034b..74772943691 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -371,7 +371,7 @@ class RflinkCommand(RflinkDevice): # Rflink protocol/transport handles asynchronous writing of buffer # to serial/tcp device. Does not wait for command send # confirmation. - self.hass.loop.run_in_executor(None, ft.partial( + self.hass.async_add_job(ft.partial( self._protocol.send_command, self._device_id, cmd)) if repetitions > 1: diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 5b147fbb656..dd46f469a55 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -112,4 +112,4 @@ class Scene(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor(None, self.activate) + return self.hass.async_add_job(self.activate) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 786319f4200..0af5dcf4a5c 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -122,8 +122,8 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, os.path.join( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 91bcf6c4aa5..9de81fb9402 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -72,8 +72,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): @asyncio.coroutine def _learn_command(call): try: - auth = yield from hass.loop.run_in_executor(None, - broadlink_device.auth) + auth = yield from hass.async_add_job(broadlink_device.auth) except socket.timeout: _LOGGER.error("Failed to connect to device, timeout") return @@ -81,14 +80,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Failed to connect to device") return - yield from hass.loop.run_in_executor( - None, broadlink_device.enter_learning) + yield from hass.async_add_job(broadlink_device.enter_learning) _LOGGER.info("Press the key you want HASS to learn") start_time = utcnow() while (utcnow() - start_time) < timedelta(seconds=20): - packet = yield from hass.loop.run_in_executor( - None, broadlink_device.check_data) + packet = yield from hass.async_add_job( + broadlink_device.check_data) if packet: log_msg = "Recieved packet is: {}".\ format(b64encode(packet).decode('utf8')) @@ -108,13 +106,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for retry in range(DEFAULT_RETRY): try: payload = b64decode(packet) - yield from hass.loop.run_in_executor( - None, broadlink_device.send_data, payload) + yield from hass.async_add_job( + broadlink_device.send_data, payload) break except (socket.timeout, ValueError): try: - yield from hass.loop.run_in_executor( - None, broadlink_device.auth) + yield from hass.async_add_job( + broadlink_device.auth) except socket.timeout: if retry == DEFAULT_RETRY-1: _LOGGER.error("Failed to send packet to device") diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index e55d3a089e9..e8f0bc9eeec 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -179,8 +179,8 @@ def load_data(url=None, file=None, username=None, password=None): def async_setup(hass, config): """Set up the Telegram bot component.""" conf = config[DOMAIN] - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) @asyncio.coroutine diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index c91ab47a324..f82d3fa5e88 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -96,8 +96,8 @@ def async_setup(hass, config): hass.http.register_view(TextToSpeechView(tts)) - descriptions = yield from hass.loop.run_in_executor( - None, load_yaml_config_file, + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) @asyncio.coroutine @@ -113,8 +113,8 @@ def async_setup(hass, config): provider = yield from platform.async_get_engine( hass, p_config) else: - provider = yield from hass.loop.run_in_executor( - None, platform.get_engine, hass, p_config) + provider = yield from hass.async_add_job( + platform.get_engine, hass, p_config) if provider is None: _LOGGER.error("Error setting up platform %s", p_type) @@ -207,8 +207,8 @@ class SpeechManager(object): return cache_dir try: - self.cache_dir = yield from self.hass.loop.run_in_executor( - None, init_tts_cache_dir, cache_dir) + self.cache_dir = yield from self.hass.async_add_job( + init_tts_cache_dir, cache_dir) except OSError as err: raise HomeAssistantError("Can't init cache dir {}".format(err)) @@ -228,8 +228,7 @@ class SpeechManager(object): return cache try: - cache_files = yield from self.hass.loop.run_in_executor( - None, get_cache_files) + cache_files = yield from self.hass.async_add_job(get_cache_files) except OSError as err: raise HomeAssistantError("Can't read cache dir {}".format(err)) @@ -250,7 +249,7 @@ class SpeechManager(object): _LOGGER.warning( "Can't remove cache file '%s': %s", filename, err) - yield from self.hass.loop.run_in_executor(None, remove_files) + yield from self.hass.async_add_job(remove_files) self.file_cache = {} @callback @@ -355,7 +354,7 @@ class SpeechManager(object): speech.write(data) try: - yield from self.hass.loop.run_in_executor(None, save_speech) + yield from self.hass.async_add_job(save_speech) self.file_cache[key] = filename except OSError: _LOGGER.error("Can't write %s", filename) @@ -378,7 +377,7 @@ class SpeechManager(object): return speech.read() try: - data = yield from self.hass.loop.run_in_executor(None, load_speech) + data = yield from self.hass.async_add_job(load_speech) except OSError: del self.file_cache[key] raise HomeAssistantError("Can't read {}".format(voice_file)) @@ -490,9 +489,8 @@ class Provider(object): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial( - self.get_tts_audio, message, language, options=options)) + return self.hass.async_add_job( + ft.partial(self.get_tts_audio, message, language, options=options)) class TextToSpeechView(HomeAssistantView): diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 24e96a239b2..9b12507de36 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -80,8 +80,8 @@ class GoogleProvider(Provider): data = b'' for idx, part in enumerate(message_parts): - part_token = yield from self.hass.loop.run_in_executor( - None, token.calculate_token, part) + part_token = yield from self.hass.async_add_job( + token.calculate_token, part) url_param = { 'ie': 'UTF-8', diff --git a/homeassistant/config.py b/homeassistant/config.py index 1d86953c0cc..d0d7cedf370 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -231,7 +231,7 @@ def async_hass_config_yaml(hass): conf = load_yaml_config_file(path) return conf - conf = yield from hass.loop.run_in_executor(None, _load_hass_yaml_config) + conf = yield from hass.async_add_job(_load_hass_yaml_config) return conf @@ -404,8 +404,8 @@ def async_process_ha_core_config(hass, config): # If we miss some of the needed values, auto detect them if None in (hac.latitude, hac.longitude, hac.units, hac.time_zone): - info = yield from hass.loop.run_in_executor( - None, loc_util.detect_location_info) + info = yield from hass.async_add_job( + loc_util.detect_location_info) if info is None: _LOGGER.error('Could not detect location information') @@ -430,8 +430,8 @@ def async_process_ha_core_config(hass, config): if hac.elevation is None and hac.latitude is not None and \ hac.longitude is not None: - elevation = yield from hass.loop.run_in_executor( - None, loc_util.elevation, hac.latitude, hac.longitude) + elevation = yield from hass.async_add_job( + loc_util.elevation, hac.latitude, hac.longitude) hac.elevation = elevation discovered.append(('elevation', elevation)) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index f687d1e808f..767b3412caf 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -221,8 +221,7 @@ class Entity(object): # pylint: disable=no-member yield from self.async_update() else: - yield from self.hass.loop.run_in_executor( - None, self.update) + yield from self.hass.async_add_job(self.update) except Exception: # pylint: disable=broad-except _LOGGER.exception("Update for %s fails", self.entity_id) return @@ -363,8 +362,8 @@ class ToggleEntity(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.turn_on, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.turn_on, **kwargs)) def turn_off(self, **kwargs) -> None: """Turn the entity off.""" @@ -375,8 +374,8 @@ class ToggleEntity(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.loop.run_in_executor( - None, ft.partial(self.turn_off, **kwargs)) + return self.hass.async_add_job( + ft.partial(self.turn_off, **kwargs)) def toggle(self) -> None: """Toggle the entity.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 9ebad3862f3..58c12bf043e 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -151,8 +151,8 @@ class EntityComponent(object): entity_platform.async_schedule_add_entities, discovery_info ) else: - task = self.hass.loop.run_in_executor( - None, platform.setup_platform, self.hass, platform_config, + task = self.hass.async_add_job( + platform.setup_platform, self.hass, platform_config, entity_platform.schedule_add_entities, discovery_info ) yield from asyncio.wait_for( @@ -195,7 +195,7 @@ class EntityComponent(object): if hasattr(entity, 'async_update'): yield from entity.async_update() else: - yield from self.hass.loop.run_in_executor(None, entity.update) + yield from self.hass.async_add_job(entity.update) if getattr(entity, 'entity_id', None) is None: object_id = entity.name or DEVICE_DEFAULT_NAME diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 08e7a91397a..3afbac5c8dd 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -76,8 +76,8 @@ def async_get_last_state(hass, entity_id: str): with (yield from hass.data[_LOCK]): if DATA_RESTORE_CACHE not in hass.data: - yield from hass.loop.run_in_executor( - None, _load_restore_cache, hass) + yield from hass.async_add_job( + _load_restore_cache, hass) return hass.data.get(DATA_RESTORE_CACHE, {}).get(entity_id) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 96d27e3494b..e3a520fff0e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -83,7 +83,7 @@ def _async_process_requirements(hass: core.HomeAssistant, name: str, with (yield from pip_lock): for req in requirements: - ret = yield from hass.loop.run_in_executor(None, pip_install, req) + ret = yield from hass.async_add_job(pip_install, req) if not ret: _LOGGER.error("Not initializing %s because could not install " "dependency %s", name, req) @@ -184,8 +184,8 @@ def _async_setup_component(hass: core.HomeAssistant, if async_comp: result = yield from component.async_setup(hass, processed_config) else: - result = yield from hass.loop.run_in_executor( - None, component.setup, hass, processed_config) + result = yield from hass.async_add_job( + component.setup, hass, processed_config) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during setup of component %s", domain) async_notify_setup_error(hass, domain, True) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index f205b2bcc95..644c8894874 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -31,26 +31,21 @@ def test_generate_entity_id_given_keys(): 'test.another_entity']) == 'test.overwrite_hidden_true' -def test_async_update_support(event_loop): +def test_async_update_support(hass): """Test async update getting called.""" sync_update = [] async_update = [] class AsyncEntity(entity.Entity): - hass = MagicMock() entity_id = 'sensor.test' def update(self): sync_update.append([1]) ent = AsyncEntity() - ent.hass.loop = event_loop + ent.hass = hass - @asyncio.coroutine - def test(): - yield from ent.async_update_ha_state(True) - - event_loop.run_until_complete(test()) + hass.loop.run_until_complete(ent.async_update_ha_state(True)) assert len(sync_update) == 1 assert len(async_update) == 0 @@ -62,7 +57,7 @@ def test_async_update_support(event_loop): ent.async_update = async_update_func - event_loop.run_until_complete(test()) + hass.loop.run_until_complete(ent.async_update_ha_state(True)) assert len(sync_update) == 1 assert len(async_update) == 1 From 910020bc5f520911d2bd7daa121f2a283b09e195 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Fri, 26 May 2017 21:05:12 +0200 Subject: [PATCH 055/105] Fix Telegram Bot send file to multiple targets, snapshots of HA cameras, variable templating, digest auth (#7771) * fix double template rendering when messages come from notify.telegram * fix 'chat' information not present in callback queries * better inline keyboards with yaml To make a row of InlineKeyboardButtons you pass: - a list of tuples like: `[(text_b1, data_callback_b1), (text_b2, data_callback_b2), ...] - a string like: `/cmd1, /cmd2, /cmd3` - or a string like: `text_b1:/cmd1, text_b2:/cmd2` Example: ```yaml data: message: 'TV is off' disable_notification: true inline_keyboard: - TV ON:/service_call switch.turn_on switch.tv, Other:/othercmd - /help, /init ``` * fix send file to multiple targets * fix message templating, multiple file targets, HA cameras - Allow templating for caption, url, file, longitude and latitude fields - Fix send a file to multiple targets - Load data with some retrying for HA cameras, which return 500 one or two times sometimes (generic cams, always!). - Doc in services for new inline keyboards yaml syntax: `Text button:/command` * HttpDigest authentication as proposed in #7396 * review changes - Don't use `file` as variable name. - For loop - Simplify filter allowed `chat_id`s. * Don't use `file` as variable name! * make params outside the while loop * fix chat_id validation when editing sent messages --- .../components/telegram_bot/__init__.py | 179 ++++++++++++------ .../components/telegram_bot/services.yaml | 2 +- 2 files changed, 117 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index e8f0bc9eeec..9bcc5a82b58 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -12,14 +12,17 @@ import logging import os import requests +from requests.auth import HTTPBasicAuth, HTTPDigestAuth 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) + CONF_PLATFORM, CONF_API_KEY, CONF_TIMEOUT, ATTR_LATITUDE, ATTR_LONGITUDE, + HTTP_DIGEST_AUTHENTICATION) import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform REQUIREMENTS = ['python-telegram-bot==6.0.1'] @@ -27,6 +30,7 @@ REQUIREMENTS = ['python-telegram-bot==6.0.1'] _LOGGER = logging.getLogger(__name__) ATTR_ARGS = 'args' +ATTR_AUTHENTICATION = 'authentication' ATTR_CALLBACK_QUERY = 'callback_query' ATTR_CALLBACK_QUERY_ID = 'callback_query_id' ATTR_CAPTION = 'caption' @@ -104,16 +108,17 @@ SERVICE_SCHEMA_SEND_MESSAGE = BASE_SERVICE_SCHEMA.extend({ 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_URL): cv.template, + vol.Optional(ATTR_FILE): cv.template, + vol.Optional(ATTR_CAPTION): cv.template, vol.Optional(ATTR_USERNAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_AUTHENTICATION): 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, + vol.Required(ATTR_LONGITUDE): cv.template, + vol.Required(ATTR_LATITUDE): cv.template, }) SERVICE_EDIT_MESSAGE = 'edit_message' SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend({ @@ -124,7 +129,7 @@ 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): vol.Coerce(int), - vol.Required(ATTR_CAPTION): cv.string, + vol.Required(ATTR_CAPTION): cv.template, vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, }, extra=vol.ALLOW_EXTRA) SERVICE_EDIT_REPLYMARKUP = 'edit_replymarkup' @@ -136,7 +141,7 @@ SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema({ 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.Required(ATTR_CALLBACK_QUERY_ID): vol.Coerce(int), vol.Optional(ATTR_SHOW_ALERT): cv.boolean, }, extra=vol.ALLOW_EXTRA) @@ -152,24 +157,42 @@ SERVICE_MAP = { } -def load_data(url=None, file=None, username=None, password=None): +def load_data(url=None, filepath=None, + username=None, password=None, + authentication=None, num_retries=5): """Load photo/document into ByteIO/File container from a source.""" try: if url is not None: # Load photo from URL + params = {"timeout": 15} 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: + if authentication == HTTP_DIGEST_AUTHENTICATION: + params["auth"] = HTTPDigestAuth(username, password) + else: + params["auth"] = HTTPBasicAuth(username, password) + retry_num = 0 + while retry_num < num_retries: + req = requests.get(url, **params) + if not req.ok: + _LOGGER.warning("Status code %s (retry #%s) loading %s.", + req.status_code, retry_num + 1, url) + else: + data = io.BytesIO(req.content) + if data.read(): + data.seek(0) + return data + _LOGGER.warning("Empty data (retry #%s) in %s).", + retry_num + 1, url) + retry_num += 1 + _LOGGER.warning("Can't load photo in %s after %s retries.", + url, retry_num) + elif filepath is not None: # Load photo from file - return open(file, "rb") + return open(filepath, "rb") else: _LOGGER.warning("Can't load photo. No photo found in params!") - except OSError as error: + except (OSError, TypeError) as error: _LOGGER.error("Can't load photo into ByteIO: %s", error) return None @@ -219,13 +242,24 @@ def async_setup(hass, config): def _render_template_attr(data, attribute): attribute_templ = data.get(attribute) if attribute_templ: - attribute_templ.hass = hass - data[attribute] = attribute_templ.async_render() + if any([isinstance(attribute_templ, vtype) + for vtype in [float, int, str]]): + data[attribute] = attribute_templ + else: + attribute_templ.hass = hass + try: + data[attribute] = attribute_templ.async_render() + except TemplateError as exc: + _LOGGER.error( + "TemplateError in %s: %s -> %s", + attribute, attribute_templ.template, exc) + data[attribute] = attribute_templ.template msgtype = service.service kwargs = dict(service.data) - _render_template_attr(kwargs, ATTR_MESSAGE) - _render_template_attr(kwargs, ATTR_TITLE) + for attribute in [ATTR_MESSAGE, ATTR_TITLE, ATTR_URL, ATTR_FILE, + ATTR_CAPTION, ATTR_LONGITUDE, ATTR_LATITUDE]: + _render_template_attr(kwargs, attribute) _LOGGER.debug("NEW telegram_message %s: %s", msgtype, kwargs) if msgtype == SERVICE_SEND_MESSAGE: @@ -296,48 +330,56 @@ class TelegramNotificationService: return message_id, inline_message_id def _get_target_chat_ids(self, target): - """Validate chat_id targets or return default target (fist defined). + """Validate chat_id targets or return default target (first). - :param target: optional list of strings or ints (['12234'] or [12234]) + :param target: optional list of integers ([12234, -12345]) :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) + target = [target] + chat_ids = [t for t in target if t in self.allowed_chat_ids] + if chat_ids: + return chat_ids + _LOGGER.warning("Unallowed targets: %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. + def _make_row_inline_keyboard(row_keyboard): + """Make a list of InlineKeyboardButtons. - :param row_keyboard: [(text_b1, data_callback_b1), - (text_b2, data_callback_b2), ...] + It can accept: + - a list of tuples like: + `[(text_b1, data_callback_b1), + (text_b2, data_callback_b2), ...] + - a string like: `/cmd1, /cmd2, /cmd3` + - or a string like: `text_b1:/cmd1, text_b2:/cmd2` """ from telegram import InlineKeyboardButton + buttons = [] if isinstance(row_keyboard, str): - return [InlineKeyboardButton( - key.strip()[1:].upper(), callback_data=key) - for key in row_keyboard.split(",")] + for key in row_keyboard.split(","): + if ':/' in key: + # commands like: 'Label:/cmd' become ('Label', '/cmd') + label = key.split(':/')[0] + command = key[len(label) + 1:] + buttons.append( + InlineKeyboardButton(label, callback_data=command)) + else: + # commands like: '/cmd' become ('CMD', '/cmd') + label = key.strip()[1:].upper() + buttons.append( + InlineKeyboardButton(label, callback_data=key)) elif isinstance(row_keyboard, list): - return [InlineKeyboardButton( - text_btn, callback_data=data_btn) - for text_btn, data_btn in row_keyboard] + for entry in row_keyboard: + text_btn, data_btn = entry + buttons.append( + InlineKeyboardButton(text_btn, callback_data=data_btn)) else: raise ValueError(str(row_keyboard)) + return buttons # Defaults params = { @@ -372,7 +414,7 @@ class TelegramNotificationService: 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]) + [_make_row_inline_keyboard(row) for row in keys]) return params def _send_msg(self, func_send, msg_error, *args_rep, **kwargs_rep): @@ -446,20 +488,26 @@ class TelegramNotificationService: 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) + file_content = load_data( + url=kwargs.get(ATTR_URL), + filepath=kwargs.get(ATTR_FILE), + username=kwargs.get(ATTR_USERNAME), + password=kwargs.get(ATTR_PASSWORD), + authentication=kwargs.get(ATTR_AUTHENTICATION), + ) + if file_content: + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug("send file to chat_id %s. Caption: %s.", + chat_id, caption) + self._send_msg(func_send, "Error sending file", + chat_id, io.BytesIO(file_content.read()), + caption=caption, **params) + file_content.seek(0) + else: + _LOGGER.error("Can't send file with kwargs: %s", kwargs) def send_location(self, latitude, longitude, target=None, **kwargs): """Send a location.""" @@ -495,18 +543,23 @@ class BaseTelegramBotEntity: _LOGGER.error("Incoming message does not have required data (%s)", msg_data) return False, None - if msg_data['from'].get('id') not in self.allowed_chat_ids \ - or msg_data['chat'].get('id') not in self.allowed_chat_ids: + + if (msg_data['from'].get('id') not in self.allowed_chat_ids or + ('chat' in msg_data and + msg_data['chat'].get('id') not in self.allowed_chat_ids)): # Origin is not allowed. _LOGGER.error("Incoming message is not allowed (%s)", msg_data) return True, None - return True, { + data = { ATTR_USER_ID: msg_data['from']['id'], - ATTR_CHAT_ID: msg_data['chat']['id'], ATTR_FROM_FIRST: msg_data['from']['first_name'], ATTR_FROM_LAST: msg_data['from']['last_name'] } + if 'chat' in msg_data: + data[ATTR_CHAT_ID] = msg_data['chat']['id'] + + return True, data def process_message(self, data): """Check for basic message rules and fire an event if message is ok.""" diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 4ce932d5f41..60828d91cc3 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -32,7 +32,7 @@ send_message: 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"]]]' + example: '["/button1, /button2", "/button3"] or ["Text button1:/button1, Text button2:/button2", "Text button3:/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' send_photo: description: Send a photo From e68bd0457c0a6f8f96c085a98e8fcf8947ebd2fd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 26 May 2017 13:12:17 -0700 Subject: [PATCH 056/105] Fix more deprecation warnings (#7778) * Remove setting up an hbmqtt broker * Don't pass loop to web.Application in tests * Use .query instead of deprecated .GET for aiohttp requests * Fix closing file resource * Do not use asyncio mark * Notify.html5 - PyJWT: Use options to disable verify * Yamaha: Test was still using deprecated ip * Remove pytest-asyncio --- homeassistant/components/api.py | 2 +- homeassistant/components/binary_sensor/mystrom.py | 2 +- homeassistant/components/camera/__init__.py | 2 +- homeassistant/components/device_tracker/gpslogger.py | 2 +- homeassistant/components/device_tracker/locative.py | 2 +- homeassistant/components/history.py | 4 ++-- homeassistant/components/http/auth.py | 4 ++-- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/media_player/spotify.py | 2 +- homeassistant/components/notify/html5.py | 2 +- homeassistant/components/sensor/fitbit.py | 2 +- homeassistant/components/sensor/torque.py | 2 +- homeassistant/components/switch/netio.py | 2 +- requirements_test.txt | 1 - requirements_test_all.txt | 1 - tests/common.py | 2 +- tests/components/device_tracker/test_mqtt_json.py | 3 --- tests/components/media_player/test_yamaha.py | 2 +- tests/components/notify/test_command_line.py | 6 +++--- tests/test_config.py | 3 ++- 20 files changed, 22 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 8beb737ae89..b722fc6ebb4 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -83,7 +83,7 @@ class APIEventStream(HomeAssistantView): stop_obj = object() to_write = asyncio.Queue(loop=hass.loop) - restrict = request.GET.get('restrict') + restrict = request.query.get('restrict') if restrict: restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP] diff --git a/homeassistant/components/binary_sensor/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py index c551a8c4efe..08ab1f4a8b7 100644 --- a/homeassistant/components/binary_sensor/mystrom.py +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -38,7 +38,7 @@ class MyStromView(HomeAssistantView): @asyncio.coroutine def get(self, request): """The GET request received from a myStrom button.""" - res = yield from self._handle(request.app['hass'], request.GET) + res = yield from self._handle(request.app['hass'], request.query) return res @asyncio.coroutine diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index d33dc996afd..7c21b99ddda 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -241,7 +241,7 @@ class CameraView(HomeAssistantView): return web.Response(status=status) authenticated = (request[KEY_AUTHENTICATED] or - request.GET.get('token') in camera.access_tokens) + request.query.get('token') in camera.access_tokens) if not authenticated: return web.Response(status=401) diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index 81961b13f7f..b88245ac9a5 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -39,7 +39,7 @@ class GPSLoggerView(HomeAssistantView): @asyncio.coroutine def get(self, request): """Handle for GPSLogger message received as GET.""" - res = yield from self._handle(request.app['hass'], request.GET) + res = yield from self._handle(request.app['hass'], request.query) return res @asyncio.coroutine diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py index b41326c4192..ced24edde48 100644 --- a/homeassistant/components/device_tracker/locative.py +++ b/homeassistant/components/device_tracker/locative.py @@ -41,7 +41,7 @@ class LocativeView(HomeAssistantView): @asyncio.coroutine def get(self, request): """Locative message received as GET.""" - res = yield from self._handle(request.app['hass'], request.GET) + res = yield from self._handle(request.app['hass'], request.query) return res @asyncio.coroutine diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 5a960ae894b..8faf8f30b1d 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -223,7 +223,7 @@ class HistoryPeriodView(HomeAssistantView): if start_time > now: return self.json([]) - end_time = request.GET.get('end_time') + end_time = request.query.get('end_time') if end_time: end_time = dt_util.as_utc( dt_util.parse_datetime(end_time)) @@ -231,7 +231,7 @@ class HistoryPeriodView(HomeAssistantView): return self.json_message('Invalid end_time', HTTP_BAD_REQUEST) else: end_time = start_time + one_day - entity_id = request.GET.get('filter_entity_id') + entity_id = request.query.get('filter_entity_id') result = yield from request.app['hass'].async_add_job( get_significant_states, request.app['hass'], start_time, end_time, diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 2a0413e9dbc..a00da9ee5b6 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -37,8 +37,8 @@ def auth_middleware(app, handler): # A valid auth header has been set authenticated = True - elif (DATA_API_PASSWORD in request.GET and - validate_password(request, request.GET[DATA_API_PASSWORD])): + elif (DATA_API_PASSWORD in request.query and + validate_password(request, request.query[DATA_API_PASSWORD])): authenticated = True elif is_trusted_ip(request): diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 47d018d0849..e17935814cc 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -947,7 +947,7 @@ class MediaPlayerImageView(HomeAssistantView): return web.Response(status=status) authenticated = (request[KEY_AUTHENTICATED] or - request.GET.get('token') == player.access_token) + request.query.get('token') == player.access_token) if not authenticated: return web.Response(status=401) diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 8ceb245eb03..9b9a8c5bb2d 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -110,7 +110,7 @@ class SpotifyAuthCallbackView(HomeAssistantView): def get(self, request): """Receive authorization token.""" hass = request.app['hass'] - self.oauth.get_access_token(request.GET['code']) + self.oauth.get_access_token(request.query['code']) hass.async_add_job(setup_platform, hass, self.config, self.add_devices) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 52d2deedcd4..f656fc0a302 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -246,7 +246,7 @@ class HTML5PushCallbackView(HomeAssistantView): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode(token, verify=False) + target_check = jwt.decode(token, options={'verify_signature': False}) if target_check[ATTR_TARGET] in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index d976da723d6..80e452e7b37 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -299,7 +299,7 @@ class FitbitAuthCallbackView(HomeAssistantView): from oauthlib.oauth2.rfc6749.errors import MissingTokenError hass = request.app['hass'] - data = request.GET + data = request.query response_message = """Fitbit has been successfully authorized! You can close this window now!""" diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index acc7958ea7f..3ce277f794b 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -75,7 +75,7 @@ class TorqueReceiveDataView(HomeAssistantView): def get(self, request): """Handle Torque data request.""" hass = request.app['hass'] - data = request.GET + data = request.query if self.email is not None and self.email != data[SENSOR_EMAIL_FIELD]: return diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py index 3351f39eeea..2a72703c5df 100644 --- a/homeassistant/components/switch/netio.py +++ b/homeassistant/components/switch/netio.py @@ -95,7 +95,7 @@ class NetioApiView(HomeAssistantView): def get(self, request, host): """Request handler.""" hass = request.app['hass'] - data = request.GET + data = request.query states, consumptions, cumulated_consumptions, start_dates = \ [], [], [], [] diff --git a/requirements_test.txt b/requirements_test.txt index c2c5cf65f1b..a584e97b5c7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,6 @@ 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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de96de19214..06acb00af15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -9,7 +9,6 @@ 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 diff --git a/tests/common.py b/tests/common.py index 735b1dfce98..dec5bdac4a4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -250,7 +250,7 @@ def mock_http_component_app(hass, api_password=None): """Create an aiohttp.web.Application instance for testing.""" if 'http' not in hass.config.components: mock_http_component(hass, api_password) - app = web.Application(middlewares=[auth_middleware], loop=hass.loop) + app = web.Application(middlewares=[auth_middleware]) app['hass'] = hass app[KEY_USE_X_FORWARDED_FOR] = False app[KEY_BANS_ENABLED] = False diff --git a/tests/components/device_tracker/test_mqtt_json.py b/tests/components/device_tracker/test_mqtt_json.py index fdca113a7ff..1755f424d29 100644 --- a/tests/components/device_tracker/test_mqtt_json.py +++ b/tests/components/device_tracker/test_mqtt_json.py @@ -69,7 +69,6 @@ class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): topic = 'location/zanzito' location = json.dumps(LOCATION_MESSAGE) - self.hass.config.components = set(['mqtt_json', 'zone']) assert setup_component(self.hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'mqtt_json', @@ -88,7 +87,6 @@ class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): topic = 'location/zanzito' location = 'home' - self.hass.config.components = set(['mqtt_json']) assert setup_component(self.hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'mqtt_json', @@ -110,7 +108,6 @@ class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): topic = 'location/zanzito' location = json.dumps(LOCATION_MESSAGE_INCOMPLETE) - self.hass.config.components = set(['mqtt_json']) assert setup_component(self.hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'mqtt_json', diff --git a/tests/components/media_player/test_yamaha.py b/tests/components/media_player/test_yamaha.py index 7484b18a904..8cea5f7c63e 100644 --- a/tests/components/media_player/test_yamaha.py +++ b/tests/components/media_player/test_yamaha.py @@ -73,7 +73,7 @@ class TestYamaha(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" super(TestYamaha, self).setUp() - self.rec = FakeYamaha('10.0.0.0') + self.rec = FakeYamaha("http://10.0.0.0:80/YamahaRemoteControl/ctrl") def test_get_playback_support(self): """Test the playback.""" diff --git a/tests/components/notify/test_command_line.py b/tests/components/notify/test_command_line.py index e66f2647d4f..2575e1418f4 100644 --- a/tests/components/notify/test_command_line.py +++ b/tests/components/notify/test_command_line.py @@ -63,9 +63,9 @@ class TestCommandLine(unittest.TestCase): blocking=True) ) - result = open(filename).read() - # the echo command adds a line break - self.assertEqual(result, "{}\n".format(message)) + with open(filename) as fil: + # the echo command adds a line break + self.assertEqual(fil.read(), "{}\n".format(message)) @patch('homeassistant.components.notify.command_line._LOGGER.error') def test_error_for_none_zero_exit_code(self, mock_error): diff --git a/tests/test_config.py b/tests/test_config.py index c555c879300..6d0932cd9a2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ """Test config utils.""" # pylint: disable=protected-access +import asyncio import os import unittest import unittest.mock as mock @@ -546,7 +547,7 @@ def test_merge_duplicate_keys(merge_log_err): assert len(config['input_select']) == 1 -@pytest.mark.asyncio +@asyncio.coroutine def test_merge_customize(hass): """Test loading core config onto hass object.""" core_config = { From 7fb5488058246bac99046e3a64e0f6346797282f Mon Sep 17 00:00:00 2001 From: sander76 Date: Fri, 26 May 2017 22:19:19 +0200 Subject: [PATCH 057/105] Powerview to async (#7682) * first commit * first commit * first commit * first commit * changing requirements * updated requirements_all.txt * various changes as suggested in the comments. * using global values for dict keys. --- .../scene/hunterdouglas_powerview.py | 90 +++++++++++-------- requirements_all.txt | 6 +- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/scene/hunterdouglas_powerview.py b/homeassistant/components/scene/hunterdouglas_powerview.py index 622acbd2583..0f5ba85c342 100644 --- a/homeassistant/components/scene/hunterdouglas_powerview.py +++ b/homeassistant/components/scene/hunterdouglas_powerview.py @@ -4,76 +4,92 @@ Support for Powerview scenes from a Powerview hub. For more details about this component, please refer to the documentation at https://home-assistant.io/components/scene.hunterdouglas_powerview/ """ +import asyncio import logging +import voluptuous as vol + from homeassistant.components.scene import Scene, DOMAIN -from homeassistant.helpers.entity import generate_entity_id +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import async_generate_entity_id _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = [ - 'https://github.com/sander76/powerviewApi/archive' - '/246e782d60d5c0addcc98d7899a0186f9d5640b0.zip#powerviewApi==0.3.15' -] +REQUIREMENTS = ['aiopvapi==1.4'] +ENTITY_ID_FORMAT = DOMAIN + '.{}' HUB_ADDRESS = 'address' +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'hunterdouglas_powerview', + vol.Required(HUB_ADDRESS): cv.string, +}) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the powerview scenes stored in a Powerview hub.""" - from powerview_api import powerview +SCENE_DATA = 'sceneData' +ROOM_DATA = 'roomData' +SCENE_NAME = 'name' +ROOM_NAME = 'name' +SCENE_ID = 'id' +ROOM_ID = 'id' +ROOM_ID_IN_SCENE = 'roomId' +STATE_ATTRIBUTE_ROOM_NAME = 'roomName' + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up home assistant scene entries.""" + from aiopvapi.hub import Hub hub_address = config.get(HUB_ADDRESS) + websession = async_get_clientsession(hass) - _pv = powerview.PowerView(hub_address) - try: - _scenes = _pv.get_scenes() - _rooms = _pv.get_rooms() - except ConnectionError: - _LOGGER.exception("Error connecting to powerview " - "hub with ip address: %s", hub_address) - return False - add_devices(PowerViewScene(hass, scene, _rooms, _pv) - for scene in _scenes['sceneData']) + _hub = Hub(hub_address, hass.loop, websession) + _scenes = yield from _hub.scenes.get_scenes() + _rooms = yield from _hub.rooms.get_rooms() - return True + if not _scenes or not _rooms: + return + pvscenes = (PowerViewScene(hass, _scene, _rooms, _hub) + for _scene in _scenes[SCENE_DATA]) + async_add_devices(pvscenes) class PowerViewScene(Scene): """Representation of a Powerview scene.""" - def __init__(self, hass, scene_data, room_data, pv_instance): + def __init__(self, hass, scene_data, room_data, hub): """Initialize the scene.""" - self.pv_instance = pv_instance + self.hub = hub self.hass = hass - self.scene_data = scene_data - self._sync_room_data(room_data) - self.entity_id_format = DOMAIN + '.{}' - self.entity_id = generate_entity_id( - self.entity_id_format, str(self.scene_data["id"]), hass=hass) + self._sync_room_data(room_data, scene_data) + self._name = scene_data[SCENE_NAME] + self._scene_id = scene_data[SCENE_ID] + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, str(scene_data[SCENE_ID]), hass=hass) - def _sync_room_data(self, room_data): + def _sync_room_data(self, room_data, scene_data): """Sync the room data.""" - room = next((room for room in room_data["roomData"] - if room["id"] == self.scene_data["roomId"]), None) - if room is not None: - self.scene_data["roomName"] = room["name"] + room = next((room for room in room_data[ROOM_DATA] + if room[ROOM_ID] == scene_data[ROOM_ID_IN_SCENE]), {}) + + self._room_name = room.get(ROOM_NAME, '') @property def name(self): """Return the name of the scene.""" - return str(self.scene_data["name"]) + return self._name @property def device_state_attributes(self): """Return the state attributes.""" - return {"roomName": self.scene_data["roomName"]} + return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} @property def icon(self): """Icon to use in the frontend.""" return 'mdi:blinds' - def activate(self): - """Activate the scene. Tries to get entities into requested state.""" - self.pv_instance.activate_scene(self.scene_data["id"]) + def async_activate(self): + """Activate scene. Try to get entities into requested state.""" + yield from self.hub.scenes.activate_scene(self._scene_id) diff --git a/requirements_all.txt b/requirements_all.txt index 7530489efd9..76163f08b4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -51,6 +51,9 @@ aiohttp_cors==0.5.3 # homeassistant.components.light.lifx aiolifx==0.4.6 +# homeassistant.components.scene.hunterdouglas_powerview +aiopvapi==1.4 + # homeassistant.components.alarmdecoder alarmdecoder==0.12.1.0 @@ -294,9 +297,6 @@ https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f # homeassistant.components.sensor.gtfs https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 -# homeassistant.components.scene.hunterdouglas_powerview -https://github.com/sander76/powerviewApi/archive/246e782d60d5c0addcc98d7899a0186f9d5640b0.zip#powerviewApi==0.3.15 - # homeassistant.components.binary_sensor.flic https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4 From 10367eb2503a30dbd663f1d6fca47dd796d6e5b3 Mon Sep 17 00:00:00 2001 From: CTLS Date: Sun, 28 May 2017 05:06:18 -0500 Subject: [PATCH 058/105] Fix home/stay in concord232 (#7789) --- homeassistant/components/alarm_control_panel/concord232.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index 167b7909fe6..df815424ee9 100755 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -117,7 +117,7 @@ class Concord232Alarm(alarm.AlarmControlPanel): def alarm_arm_home(self, code=None): """Send arm home command.""" - self._alarm.arm('home') + self._alarm.arm('stay') def alarm_arm_away(self, code=None): """Send arm away command.""" From 1f3bb51821ddb3b6886bf281667ea89f94b632df Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 29 May 2017 10:26:10 +0200 Subject: [PATCH 059/105] Add Marantz SSDP discovery / Detect error string in AppCommand.xml body (#7779) --- 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 0a4ec012382..5fdfbcfb864 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.1'] +REQUIREMENTS = ['denonavr==0.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 76163f08b4d..e0c8315f0ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -141,7 +141,7 @@ datapoint==0.4.3 # decora==0.4 # homeassistant.components.media_player.denonavr -denonavr==0.4.1 +denonavr==0.4.2 # homeassistant.components.media_player.directv directpy==0.1 From 010f098df3895c428d74e213b23670d59706d2f7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 29 May 2017 10:26:33 +0200 Subject: [PATCH 060/105] Upgrade Sphinx to 1.6.2 (#7805) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 8e85b302a6b..eb217ec94ec 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.6.1 +Sphinx==1.6.2 sphinx-autodoc-typehints==1.2.0 sphinx-autodoc-annotation==1.0.post1 From 8b7894fb86009e80825a81c9c9dfa4633f9c7990 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 29 May 2017 10:26:56 +0200 Subject: [PATCH 061/105] Upgrade slacker to 0.9.50 (#7797) --- homeassistant/components/notify/slack.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 8f63fcd7a2f..fa7332326da 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_API_KEY, CONF_USERNAME, CONF_ICON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['slacker==0.9.42'] +REQUIREMENTS = ['slacker==0.9.50'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e0c8315f0ef..9934dbd4974 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -769,7 +769,7 @@ sharp_aquos_rc==0.3.2 simplisafe-python==1.0.2 # homeassistant.components.notify.slack -slacker==0.9.42 +slacker==0.9.50 # homeassistant.components.notify.xmpp sleekxmpp==1.3.2 From ef51d8518a229799dfb254b970dfddfd3b37c59c Mon Sep 17 00:00:00 2001 From: Dan Cinnamon Date: Mon, 29 May 2017 03:27:36 -0500 Subject: [PATCH 062/105] Bump pyenvisalink to version 2.1 (#7803) --- homeassistant/components/envisalink.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/envisalink.py b/homeassistant/components/envisalink.py index 8aa41f6274a..87b6163282a 100644 --- a/homeassistant/components/envisalink.py +++ b/homeassistant/components/envisalink.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['pyenvisalink==2.0'] +REQUIREMENTS = ['pyenvisalink==2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9934dbd4974..1c122d133bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -527,7 +527,7 @@ pyeight==0.0.6 pyemby==1.2 # homeassistant.components.envisalink -pyenvisalink==2.0 +pyenvisalink==2.1 # homeassistant.components.sensor.fido pyfido==1.0.1 From c12b8f763ce7123bfd6d09d21ed03fac9222ded5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 29 May 2017 10:28:31 +0200 Subject: [PATCH 063/105] Upgrade pysnmp to 4.3.6 (#7806) --- homeassistant/components/device_tracker/snmp.py | 2 +- homeassistant/components/sensor/snmp.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 1449ae6dbef..6033f491aae 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.3.5'] +REQUIREMENTS = ['pysnmp==4.3.6'] CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 2ce08f262d7..114ef4ef76b 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pysnmp==4.3.5'] +REQUIREMENTS = ['pysnmp==4.3.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 1c122d133bc..18c40b7455f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -630,7 +630,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp -pysnmp==4.3.5 +pysnmp==4.3.6 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner From fc1bb58247b2a4320fdbea7b4f819c980d358bd1 Mon Sep 17 00:00:00 2001 From: Andy Castille Date: Mon, 29 May 2017 04:15:27 -0500 Subject: [PATCH 064/105] Rachio (Sprinklers) (#7600) * Rachio platform started * Rachio tests * detect bad api token * Documentation, Code cleanup * Docstrings end with a period, log uses % * Fix arguments, default run time is now 10 minutes * Fix typo, remove todo (GH issue exists) * Revert polymer submodule commit * Use a RachioPy version with SSL cert validation * Update requirements --- .coveragerc | 3 + homeassistant/components/switch/rachio.py | 232 ++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 238 insertions(+) create mode 100644 homeassistant/components/switch/rachio.py diff --git a/.coveragerc b/.coveragerc index 4ba57e0f750..b02b4d12579 100644 --- a/.coveragerc +++ b/.coveragerc @@ -89,6 +89,9 @@ omit = homeassistant/components/qwikswitch.py homeassistant/components/*/qwikswitch.py + homeassistant/components/rachio.py + homeassistant/components/*/rachio.py + homeassistant/components/raspihats.py homeassistant/components/*/raspihats.py diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py new file mode 100644 index 00000000000..73183da1128 --- /dev/null +++ b/homeassistant/components/switch/rachio.py @@ -0,0 +1,232 @@ +"""Integration with the Rachio Iro sprinkler system controller.""" +import logging +from datetime import timedelta +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.util as util +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.const import CONF_ACCESS_TOKEN + +REQUIREMENTS = ['https://github.com/Klikini/rachiopy' + '/archive/2c8996fcfa97a9f361a789e0c998797ed2805281.zip' + '#rachiopy==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +DATA_RACHIO = 'rachio' + +CONF_MANUAL_RUN_MINS = 'manual_run_mins' +DEFAULT_MANUAL_RUN_MINS = 10 + +MIN_UPDATE_INTERVAL = timedelta(minutes=5) +MIN_FORCED_UPDATE_INTERVAL = timedelta(seconds=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_MANUAL_RUN_MINS, default=DEFAULT_MANUAL_RUN_MINS): + cv.positive_int +}) + + +# noinspection PyUnusedLocal +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the component.""" + # Get options + manual_run_mins = config.get(CONF_MANUAL_RUN_MINS) + _LOGGER.debug("Rachio run time is %d min", manual_run_mins) + + # Get access token + _LOGGER.debug("Getting Rachio access token...") + access_token = config.get(CONF_ACCESS_TOKEN) + + # Configure API + _LOGGER.debug("Configuring Rachio API...") + from rachiopy import Rachio + rachio = Rachio(access_token) + person = None + try: + person = _get_person(rachio) + except KeyError: + _LOGGER.error("Could not reach the Rachio API. " + "Is your access token valid?") + return False + + # Get and persist devices + devices = _list_devices(rachio, manual_run_mins) + if len(devices) == 0: + _LOGGER.error("No Rachio devices found in account " + + person['username']) + return False + else: + hass.data[DATA_RACHIO] = devices[0] + + if len(devices) > 1: + _LOGGER.warning("Multiple Rachio devices found in account, " + "using " + hass.data[DATA_RACHIO].device_id) + else: + _LOGGER.info("Found Rachio device") + + hass.data[DATA_RACHIO].update() + add_devices(hass.data[DATA_RACHIO].list_zones()) + return True + + +def _get_person(rachio): + """Pull the account info of the person whose access token was provided.""" + person_id = rachio.person.getInfo()[1]['id'] + return rachio.person.get(person_id)[1] + + +def _list_devices(rachio, manual_run_mins): + """Pull a list of devices on the account.""" + return [RachioIro(rachio, d['id'], manual_run_mins) + for d in _get_person(rachio)['devices']] + + +class RachioIro(object): + """Represents one Rachio Iro.""" + + def __init__(self, rachio, device_id, manual_run_mins): + """Initialize a new device.""" + self.rachio = rachio + self._device_id = device_id + self.manual_run_mins = manual_run_mins + self._device = None + self._running = None + self._zones = None + + def __str__(self): + """Display the device as a string.""" + return "Rachio Iro " + self.serial_number + + @property + def device_id(self): + """How the Rachio API refers to the device.""" + return self._device['id'] + + @property + def status(self): + """The current status of the device.""" + return self._device['status'] + + @property + def serial_number(self): + """The serial number of the device.""" + return self._device['serialNumber'] + + @property + def is_paused(self): + """Whether the device is temporarily disabled.""" + return self._device['paused'] + + @property + def is_on(self): + """Whether the device is powered on and connected.""" + return self._device['on'] + + @property + def current_schedule(self): + """The schedule that the device is running right now.""" + return self._running + + def list_zones(self, include_disabled=False): + """A list of the zones connected to the device and their data.""" + if not self._zones: + self._zones = [RachioZone(self.rachio, self, zone['id'], + self.manual_run_mins) + for zone in self._device['zones']] + + if include_disabled: + return self._zones + else: + self.update(no_throttle=True) + return [z for z in self._zones if z.is_enabled] + + @util.Throttle(MIN_UPDATE_INTERVAL, MIN_FORCED_UPDATE_INTERVAL) + def update(self, **kwargs): + """Pull updated device info from the Rachio API.""" + self._device = self.rachio.device.get(self._device_id)[1] + self._running = self.rachio.device\ + .getCurrentSchedule(self._device_id)[1] + + # Possibly update all zones + for zone in self.list_zones(include_disabled=True): + zone.update() + + _LOGGER.debug("Updated %s", str(self)) + + +class RachioZone(SwitchDevice): + """Represents one zone of sprinklers connected to the Rachio Iro.""" + + def __init__(self, rachio, device, zone_id, manual_run_mins): + """Initialize a new Rachio Zone.""" + self.rachio = rachio + self._device = device + self._zone_id = zone_id + self._zone = None + self._manual_run_secs = manual_run_mins * 60 + + def __str__(self): + """Display the zone as a string.""" + return "Rachio Zone " + self.name + + @property + def zone_id(self): + """How the Rachio API refers to the zone.""" + return self._zone['id'] + + @property + def unique_id(self): + """Generate a unique string ID for the zone.""" + return '{iro}-{zone}'.format( + iro=self._device.device_id, + zone=self.zone_id) + + @property + def number(self): + """The physical connection of the zone pump.""" + return self._zone['zoneNumber'] + + @property + def name(self): + """The friendly name of the zone.""" + return self._zone['name'] + + @property + def is_enabled(self): + """Whether the zone is allowed to run.""" + return self._zone['enabled'] + + @property + def is_on(self): + """Whether the zone is currently running.""" + self._device.update() + schedule = self._device.current_schedule + return self.zone_id == schedule.get('zoneId') + + def update(self): + """Pull updated zone info from the Rachio API.""" + self._zone = self.rachio.zone.get(self._zone_id)[1] + + # Possibly update device + self._device.update() + + _LOGGER.debug("Updated %s", str(self)) + + def turn_on(self): + """Start the zone.""" + # Convert minutes to seconds + seconds = self._manual_run_secs * 60 + + # Stop other zones first + self.turn_off() + + _LOGGER.info("Watering %s for %d sec", self.name, seconds) + self.rachio.zone.start(self.zone_id, seconds) + + def turn_off(self): + """Stop all zones.""" + _LOGGER.info("Stopping watering of all zones") + self.rachio.device.stopWater(self._device.device_id) diff --git a/requirements_all.txt b/requirements_all.txt index 18c40b7455f..47a3a4c7fb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,6 +261,9 @@ hikvision==0.4 # homeassistant.components.binary_sensor.workday holidays==0.8.1 +# homeassistant.components.switch.rachio +https://github.com/Klikini/rachiopy/archive/2c8996fcfa97a9f361a789e0c998797ed2805281.zip#rachiopy==0.1.1 + # homeassistant.components.switch.dlink https://github.com/LinuxChristian/pyW215/archive/v0.4.zip#pyW215==0.4 From eff619a58f49e5438e5f47415ad3475aa8617347 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 29 May 2017 03:20:23 -0600 Subject: [PATCH 065/105] Rest notify data (#7757) * Rest notify data * Cleanup * Fix spaces --- homeassistant/components/notify/rest.py | 33 ++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 1a40e1f1833..19339a2c7ec 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -15,6 +15,8 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_RESOURCE, CONF_METHOD, CONF_NAME) import homeassistant.helpers.config_validation as cv +CONF_DATA = 'data' +CONF_DATA_TEMPLATE = 'data_template' CONF_MESSAGE_PARAMETER_NAME = 'message_param_name' CONF_TARGET_PARAMETER_NAME = 'target_param_name' CONF_TITLE_PARAMETER_NAME = 'title_param_name' @@ -34,6 +36,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ default=DEFAULT_TARGET_PARAM_NAME): cv.string, vol.Optional(CONF_TITLE_PARAMETER_NAME, default=DEFAULT_TITLE_PARAM_NAME): cv.string, + vol.Optional(CONF_DATA, + default=None): dict, + vol.Optional(CONF_DATA_TEMPLATE, + default=None): {cv.match_all: cv.template_complex} }) _LOGGER = logging.getLogger(__name__) @@ -46,23 +52,28 @@ def get_service(hass, config, discovery_info=None): message_param_name = config.get(CONF_MESSAGE_PARAMETER_NAME) title_param_name = config.get(CONF_TITLE_PARAMETER_NAME) target_param_name = config.get(CONF_TARGET_PARAMETER_NAME) + data = config.get(CONF_DATA) + data_template = config.get(CONF_DATA_TEMPLATE) return RestNotificationService( - resource, method, message_param_name, title_param_name, - target_param_name) + hass, resource, method, message_param_name, + title_param_name, target_param_name, data, data_template) class RestNotificationService(BaseNotificationService): """Implementation of a notification service for REST.""" - def __init__(self, resource, method, message_param_name, title_param_name, - target_param_name): + def __init__(self, hass, resource, method, message_param_name, + title_param_name, target_param_name, data, data_template): """Initialize the service.""" self._resource = resource + self._hass = hass self._method = method.upper() self._message_param_name = message_param_name self._title_param_name = title_param_name self._target_param_name = target_param_name + self._data = data + self._data_template = data_template def send_message(self, message="", **kwargs): """Send a message to a user.""" @@ -79,6 +90,20 @@ class RestNotificationService(BaseNotificationService): # integrations, so just return the first target in the list. data[self._target_param_name] = kwargs[ATTR_TARGET][0] + if self._data: + data.update(self._data) + elif self._data_template: + def _data_template_creator(value): + """Recursive template creator helper function.""" + if isinstance(value, list): + return [_data_template_creator(item) for item in value] + elif isinstance(value, dict): + return {key: _data_template_creator(item) + for key, item in value.items()} + value.hass = self._hass + return value.async_render(kwargs) + data.update(_data_template_creator(self._data_template)) + if self._method == 'POST': response = requests.post(self._resource, data=data, timeout=10) elif self._method == 'POST_JSON': From 8fcc7509986f2471c98f1179f0fc332ba1d31c24 Mon Sep 17 00:00:00 2001 From: Lev Aronsky Date: Mon, 29 May 2017 12:22:20 +0300 Subject: [PATCH 066/105] Added handling of an AssertionError from pxssh failed login (#7750) * Added handling of an AssertionError from pxssh failed login * Destory and re-create pxssh instance, to fix behavior upon router restart. --- homeassistant/components/device_tracker/asuswrt.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index cc50ab44e54..bdd28d1d168 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -296,11 +296,9 @@ class SshConnection(_Connection): def __init__(self, host, port, username, password, ssh_key, ap): """Initialize the SSH connection properties.""" - from pexpect import pxssh - super(SshConnection, self).__init__() - self._ssh = pxssh.pxssh() + self._ssh = None self._host = host self._port = port self._username = username @@ -348,9 +346,16 @@ class SshConnection(_Connection): _LOGGER.error("Unexpected SSH error: %s", str(err)) self.disconnect() return None + except AssertionError as err: + _LOGGER.error("Connection to router unavailable: %s", str(err)) + self.disconnect() + return None def connect(self): """Connect to the ASUS-WRT SSH server.""" + from pexpect import pxssh + + self._ssh = pxssh.pxssh() if self._ssh_key: self._ssh.login(self._host, self._username, ssh_key=self._ssh_key, port=self._port) @@ -367,6 +372,8 @@ class SshConnection(_Connection): self._ssh.logout() except Exception: pass + finally: + self._ssh = None super(SshConnection, self).disconnect() From ba44b7edb368dfe0bacf7fbf92884cbb340a32dd Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 29 May 2017 15:38:56 +0200 Subject: [PATCH 067/105] Upgrade sqlalchemy to 1.1.10 (#7807) --- homeassistant/components/recorder/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 997c79f6975..54ee81091c8 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -33,7 +33,7 @@ from . import purge, migration from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.1.9'] +REQUIREMENTS = ['sqlalchemy==1.1.10'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 47a3a4c7fb1..a30df77cf87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -794,7 +794,7 @@ speedtest-cli==1.0.6 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.9 +sqlalchemy==1.1.10 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06acb00af15..95bb4303e09 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -117,7 +117,7 @@ somecomfort==0.4.1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.9 +sqlalchemy==1.1.10 # homeassistant.components.statsd statsd==3.2.1 From a7277db4d76f7072055088752b7cf1827a66204e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 29 May 2017 15:39:24 +0200 Subject: [PATCH 068/105] Upgrade mypy to 0.511 (#7809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index a584e97b5c7..95f9bfef329 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.501 +mypy==0.511 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95bb4303e09..ded0c661226 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.501 +mypy==0.511 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 From 5e5c0daa87b125f63308bbc7cdd9e3d2c03e232e Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 29 May 2017 16:19:50 +0200 Subject: [PATCH 069/105] Allow configuring DSMR5 protocol. (#7535) * Allow configuring DSMR5 protocol. * Give good example. * Using dev branch until released upstream. * Update to dsmr_parser supporting v5 arguments. --- homeassistant/components/sensor/dsmr.py | 7 ++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 23324fe7360..8df4776459e 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -40,7 +40,8 @@ import voluptuous as vol _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['dsmr_parser==0.8'] +REQUIREMENTS = ['dsmr_parser==0.9'] + CONF_DSMR_VERSION = 'dsmr_version' CONF_RECONNECT_INTERVAL = 'reconnect_interval' @@ -60,7 +61,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, vol.Optional(CONF_HOST, default=None): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( - cv.string, vol.In(['4', '2.2'])), + cv.string, vol.In(['5', '4', '2.2'])), vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int, }) @@ -93,7 +94,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): devices = [DSMREntity(name, obis) for name, obis in obis_mapping] # Protocol version specific obis - if dsmr_version == '4': + if dsmr_version in ('4', '5'): gas_obis = obis_ref.HOURLY_GAS_METER_READING else: gas_obis = obis_ref.GAS_METER_READING diff --git a/requirements_all.txt b/requirements_all.txt index a30df77cf87..23070d75ab8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -162,7 +162,7 @@ dnspython3==1.15.0 dovado==0.4.1 # homeassistant.components.sensor.dsmr -dsmr_parser==0.8 +dsmr_parser==0.9 # homeassistant.components.dweet # homeassistant.components.sensor.dweet diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ded0c661226..f56ab8c3194 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -37,7 +37,7 @@ aiohttp_cors==0.5.3 apns2==0.1.1 # homeassistant.components.sensor.dsmr -dsmr_parser==0.8 +dsmr_parser==0.9 # homeassistant.components.climate.honeywell evohomeclient==0.2.5 From f7e0d13fe60076ae7eec9023c7c2cf6af0252924 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Mon, 29 May 2017 22:59:44 +0200 Subject: [PATCH 070/105] Telegram send image: fix mimetype detection (#7802) * Add `name` var to BytesIO content to get recognized Sometimes the python-telegram-bot doesn't recognize the mimetype of the file and looks after a name variable to deduce it. Fixes #7413 * bytesio stream recycle less explicit --- homeassistant/components/telegram_bot/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 9bcc5a82b58..127caba34a0 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -180,6 +180,7 @@ def load_data(url=None, filepath=None, data = io.BytesIO(req.content) if data.read(): data.seek(0) + data.name = url return data _LOGGER.warning("Empty data (retry #%s) in %s).", retry_num + 1, url) @@ -503,7 +504,7 @@ class TelegramNotificationService: _LOGGER.debug("send file to chat_id %s. Caption: %s.", chat_id, caption) self._send_msg(func_send, "Error sending file", - chat_id, io.BytesIO(file_content.read()), + chat_id, file_content, caption=caption, **params) file_content.seek(0) else: From 81b21117517c15e4b5593fc130e76e35a81247e1 Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Tue, 30 May 2017 00:54:16 -0400 Subject: [PATCH 071/105] Bump aiohttp to 2.1.0 (#7825) --- 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 d65d9438716..28e392edf31 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ pip>=7.1.0 jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.0.7 +aiohttp==2.1.0 async_timeout==1.2.1 chardet==3.0.2 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 23070d75ab8..f354ecf6ec7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,7 @@ pip>=7.1.0 jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.0.7 +aiohttp==2.1.0 async_timeout==1.2.1 chardet==3.0.2 astral==1.4 diff --git a/setup.py b/setup.py index d067a9019b3..c0accb33b6f 100755 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ REQUIRES = [ 'jinja2>=2.9.5', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.0.7', + 'aiohttp==2.1.0', 'async_timeout==1.2.1', 'chardet==3.0.2', 'astral==1.4', From 2df6aabbf3e0cef483d48e6d6e1c57b66a266f68 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 30 May 2017 06:55:06 +0200 Subject: [PATCH 072/105] Cleanup telegram / Add url to webhook (#7824) * Cleanup telegram / Add url to webhook * fix lint * Fix lint --- .../components/telegram_bot/__init__.py | 56 ++++++++----------- .../components/telegram_bot/polling.py | 5 +- .../components/telegram_bot/webhooks.py | 33 +++++++++-- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 127caba34a0..e5b57558989 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/telegram_bot/ import asyncio import io from functools import partial -from ipaddress import ip_network import logging import os @@ -60,10 +59,18 @@ ATTR_USER_ID = 'user_id' ATTR_USERNAME = 'username' CONF_ALLOWED_CHAT_IDS = 'allowed_chat_ids' -CONF_TRUSTED_NETWORKS = 'trusted_networks' DOMAIN = 'telegram_bot' +SERVICE_SEND_MESSAGE = 'send_message' +SERVICE_SEND_PHOTO = 'send_photo' +SERVICE_SEND_DOCUMENT = 'send_document' +SERVICE_SEND_LOCATION = 'send_location' +SERVICE_EDIT_MESSAGE = 'edit_message' +SERVICE_EDIT_CAPTION = 'edit_caption' +SERVICE_EDIT_REPLYMARKUP = 'edit_replymarkup' +SERVICE_ANSWER_CALLBACK_QUERY = 'answer_callback_query' + EVENT_TELEGRAM_CALLBACK = 'telegram_callback' EVENT_TELEGRAM_COMMAND = 'telegram_command' EVENT_TELEGRAM_TEXT = 'telegram_text' @@ -71,26 +78,13 @@ EVENT_TELEGRAM_TEXT = 'telegram_text' PARSER_HTML = 'html' PARSER_MD = 'markdown' -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') -] - -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, [vol.Coerce(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) +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PLATFORM): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ALLOWED_CHAT_IDS): + vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(ATTR_PARSER, default=PARSER_MD): cv.string, +}) BASE_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), @@ -100,13 +94,12 @@ BASE_SERVICE_SCHEMA = vol.Schema({ 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.template, vol.Optional(ATTR_FILE): cv.template, @@ -115,30 +108,30 @@ SERVICE_SCHEMA_SEND_FILE = BASE_SERVICE_SCHEMA.extend({ vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_AUTHENTICATION): cv.string, }) -SERVICE_SEND_LOCATION = 'send_location' + SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend({ vol.Required(ATTR_LONGITUDE): cv.template, vol.Required(ATTR_LATITUDE): cv.template, }) -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): vol.Coerce(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): vol.Coerce(int), vol.Required(ATTR_CAPTION): cv.template, 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): vol.Coerce(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): vol.Coerce(int), @@ -157,8 +150,7 @@ SERVICE_MAP = { } -def load_data(url=None, filepath=None, - username=None, password=None, +def load_data(url=None, filepath=None, username=None, password=None, authentication=None, num_retries=5): """Load photo/document into ByteIO/File container from a source.""" try: diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 161c4e356a2..ff89806b292 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -12,7 +12,8 @@ import async_timeout from aiohttp.client_exceptions import ClientError from homeassistant.components.telegram_bot import ( - CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity) + CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, + PLATFORM_SCHEMA as TELEGRAM_PLATFORM_SCHEMA) from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_API_KEY) from homeassistant.core import callback @@ -20,6 +21,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = TELEGRAM_PLATFORM_SCHEMA + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 928f40b4ffc..b5378a01991 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -6,15 +6,19 @@ https://home-assistant.io/components/telegram_bot.webhooks/ """ import asyncio import datetime as dt +from ipaddress import ip_network import logging +import voluptuous as vol + from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.util import get_real_ip from homeassistant.components.telegram_bot import ( - CONF_ALLOWED_CHAT_IDS, CONF_TRUSTED_NETWORKS, BaseTelegramBotEntity) + CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, - HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED) + CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST, + HTTP_UNAUTHORIZED, CONF_URL) +import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['http'] @@ -23,6 +27,24 @@ _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') +] + +# pylint: disable=no-value-for-parameter +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_URL): vol.Url(), + vol.Optional(CONF_TRUSTED_NETWORKS, default=DEFAULT_TRUSTED_NETWORKS): + vol.All(cv.ensure_list, [ip_network]) +}) + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -31,6 +53,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): bot = telegram.Bot(config[CONF_API_KEY]) current_status = yield from hass.async_add_job(bot.getWebhookInfo) + base_url = config.get(CONF_URL, hass.config.api.base_url) # Some logging of Bot current status: last_error_date = getattr(current_status, 'last_error_date', None) @@ -40,8 +63,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 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) + + handler_url = "{0}{1}".format(base_url, TELEGRAM_HANDLER_URL) if not handler_url.startswith('https'): _LOGGER.error("Invalid telegram webhook %s must be https", handler_url) return False From 1c4e097bed5b11f36678e315d577e540e034e95c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 30 May 2017 09:08:57 +0200 Subject: [PATCH 073/105] Upgrade pysnmp to 4.3.7 (#7828) --- homeassistant/components/device_tracker/snmp.py | 2 +- homeassistant/components/sensor/snmp.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 6033f491aae..b0a2015362e 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.3.6'] +REQUIREMENTS = ['pysnmp==4.3.7'] CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 114ef4ef76b..56050838dd9 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pysnmp==4.3.6'] +REQUIREMENTS = ['pysnmp==4.3.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f354ecf6ec7..074fd989ebd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -633,7 +633,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp -pysnmp==4.3.6 +pysnmp==4.3.7 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner From 91806bfa2a949215e0b52e63ce96b8cfbe3871d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 30 May 2017 10:46:18 +0200 Subject: [PATCH 074/105] Flux led fix (#7829) * Update flux_led.py * style fix --- homeassistant/components/light/flux_led.py | 48 ++++++---------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 499ec8f74ab..8fe60d5e19e 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -12,10 +12,9 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PROTOCOL from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_EFFECT, ATTR_WHITE_VALUE, - EFFECT_COLORLOOP, EFFECT_RANDOM, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, - SUPPORT_RGB_COLOR, SUPPORT_WHITE_VALUE, Light, - PLATFORM_SCHEMA) + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_EFFECT, EFFECT_COLORLOOP, + EFFECT_RANDOM, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, + SUPPORT_RGB_COLOR, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['flux_led==0.19'] @@ -27,10 +26,8 @@ ATTR_MODE = 'mode' DOMAIN = 'flux_led' -SUPPORT_FLUX_LED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | - SUPPORT_RGB_COLOR) -SUPPORT_FLUX_LED_RGBW = (SUPPORT_WHITE_VALUE | SUPPORT_EFFECT | - SUPPORT_RGB_COLOR) +SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | + SUPPORT_RGB_COLOR) MODE_RGB = 'rgb' MODE_RGBW = 'rgbw' @@ -182,16 +179,7 @@ class FluxLight(Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - if self._mode == MODE_RGB: - return self._bulb.brightness - return None # not used for RGBW - - @property - def white_value(self): - """Return the white value of this light between 0..255.""" - if self._mode == MODE_RGBW: - return self._bulb.getRgbw()[3] - return None # not used for RGB + return self._bulb.brightness @property def rgb_color(self): @@ -201,11 +189,7 @@ class FluxLight(Light): @property def supported_features(self): """Flag supported features.""" - if self._mode == MODE_RGBW: - return SUPPORT_FLUX_LED_RGBW - elif self._mode == MODE_RGB: - return SUPPORT_FLUX_LED_RGB - return 0 + return SUPPORT_FLUX_LED @property def effect_list(self): @@ -219,23 +203,17 @@ class FluxLight(Light): rgb = kwargs.get(ATTR_RGB_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) - white_value = kwargs.get(ATTR_WHITE_VALUE) effect = kwargs.get(ATTR_EFFECT) - if rgb is not None and brightness is not None: self._bulb.setRgb(*tuple(rgb), brightness=brightness) - elif rgb is not None and white_value is not None: - self._bulb.setRgbw(*tuple(rgb), w=white_value) elif rgb is not None: - # self.white_value and self.brightness are appropriately - # returning None for MODE_RGB and MODE_RGBW respectively - self._bulb.setRgbw(*tuple(rgb), - w=self.white_value, - brightness=self.brightness) + self._bulb.setRgb(*tuple(rgb)) elif brightness is not None: - self._bulb.setRgb(*self.rgb_color, brightness=brightness) - elif white_value is not None: - self._bulb.setRgbw(*self.rgb_color, w=white_value) + if self._mode == 'rgbw': + self._bulb.setWarmWhite255(brightness) + elif self._mode == 'rgb': + (red, green, blue) = self._bulb.getRgb() + self._bulb.setRgb(red, green, blue, brightness=brightness) elif effect == EFFECT_RANDOM: self._bulb.setRgb(random.randint(0, 255), random.randint(0, 255), From 96b20b3a97a9cd5b9fa3d814cd0515bd78032f05 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Tue, 30 May 2017 05:34:39 -0400 Subject: [PATCH 075/105] update snapcast media player (#7079) * update snapcast * fix docstrings * bump dep version * address snapcast review comments * add snapcast group volume support * fix snapcast requirements * update snapcast client entity id * snapshot/restore functions * refactor snapshot/restore services * clean up * update snapcast req * bump version * fix async updates --- .../components/media_player/services.yaml | 16 ++ .../components/media_player/snapcast.py | 221 ++++++++++++++---- requirements_all.txt | 2 +- 3 files changed, 198 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 00ce0987fd9..2cf3617cc61 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -165,6 +165,22 @@ shuffle_set: description: True/false for enabling/disabling shuffle example: true +snapcast_snapshot: + description: Take a snapshot of the media player. + + fields: + entity_id: + description: Name(s) of entites that will be snapshotted. Platform dependent. + example: 'media_player.living_room' + +snapcast_restore: + description: Restore a snapshot of the media player. + + fields: + entity_id: + description: Name(s) of entites that will be restored. Platform dependent. + example: 'media_player.living_room' + sonos_join: description: Group player together. diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index f893dcdeed1..7b0036e5f96 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -4,62 +4,197 @@ Support for interacting with Snapcast clients. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.snapcast/ """ +import asyncio import logging +from os import path import socket import voluptuous as vol from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, - SUPPORT_PLAY, PLATFORM_SCHEMA, MediaPlayerDevice) + PLATFORM_SCHEMA, MediaPlayerDevice) from homeassistant.const import ( - STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, CONF_HOST, CONF_PORT) + STATE_ON, STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, CONF_HOST, + CONF_PORT, ATTR_ENTITY_ID) import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['snapcast==1.2.2'] +REQUIREMENTS = ['snapcast==2.0.6'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'snapcast' -SUPPORT_SNAPCAST = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY +SERVICE_SNAPSHOT = 'snapcast_snapshot' +SERVICE_RESTORE = 'snapcast_restore' +SUPPORT_SNAPCAST_CLIENT = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET +SUPPORT_SNAPCAST_GROUP = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET |\ + SUPPORT_SELECT_SOURCE + +GROUP_PREFIX = 'snapcast_group_' +GROUP_SUFFIX = 'Snapcast Group' +CLIENT_PREFIX = 'snapcast_client_' +CLIENT_SUFFIX = 'Snapcast Client' + +SERVICE_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_PORT): cv.port }) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Snapcast platform.""" +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup the Snapcast platform.""" import snapcast.control + from snapcast.control.server import CONTROL_PORT host = config.get(CONF_HOST) - port = config.get(CONF_PORT, snapcast.control.CONTROL_PORT) + port = config.get(CONF_PORT, CONTROL_PORT) + + @asyncio.coroutine + def _handle_service(service): + """Handle services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + devices = [device for device in hass.data[DOMAIN] + if device.entity_id in entity_ids] + for device in devices: + if service.service == SERVICE_SNAPSHOT: + device.snapshot() + elif service.service == SERVICE_RESTORE: + yield from device.async_restore() + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + hass.services.async_register( + DOMAIN, SERVICE_SNAPSHOT, _handle_service, + descriptions.get(SERVICE_SNAPSHOT), schema=SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_RESTORE, _handle_service, + descriptions.get(SERVICE_RESTORE), schema=SERVICE_SCHEMA) try: - server = snapcast.control.Snapserver(host, port) + server = yield from snapcast.control.create_server( + hass.loop, host, port) except socket.gaierror: - _LOGGER.error( - "Could not connect to Snapcast server at %s:%d", host, port) + _LOGGER.error('Could not connect to Snapcast server at %s:%d', + host, port) + return False + groups = [SnapcastGroupDevice(group) for group in server.groups] + clients = [SnapcastClientDevice(client) for client in server.clients] + devices = groups + clients + hass.data[DOMAIN] = devices + async_add_devices(devices) + return True + + +class SnapcastGroupDevice(MediaPlayerDevice): + """Representation of a Snapcast group device.""" + + def __init__(self, group): + """Initialize the Snapcast group device.""" + group.set_callback(self.schedule_update_ha_state) + self._group = group + + @property + def state(self): + """Return the state of the player.""" + return { + 'idle': STATE_IDLE, + 'playing': STATE_PLAYING, + 'unknown': STATE_UNKNOWN, + }.get(self._group.stream_status, STATE_UNKNOWN) + + @property + def name(self): + """Return the name of the device.""" + return '{}{}'.format(GROUP_PREFIX, self._group.identifier) + + @property + def source(self): + """Return the current input source.""" + return self._group.stream + + @property + def volume_level(self): + """Return the volume level.""" + return self._group.volume / 100 + + @property + def is_volume_muted(self): + """Volume muted.""" + return self._group.muted + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_SNAPCAST_GROUP + + @property + def source_list(self): + """List of available input sources.""" + return list(self._group.streams_by_name().keys()) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + name = '{} {}'.format(self._group.friendly_name, GROUP_SUFFIX) + return { + 'friendly_name': name + } + + @property + def should_poll(self): + """Do not poll for state.""" return False - add_devices([SnapcastDevice(client) for client in server.clients]) + @asyncio.coroutine + def async_select_source(self, source): + """Set input source.""" + streams = self._group.streams_by_name() + if source in streams: + yield from self._group.set_stream(streams[source].identifier) + self.hass.async_add_job(self.async_update_ha_state()) + + @asyncio.coroutine + def async_mute_volume(self, mute): + """Send the mute command.""" + yield from self._group.set_muted(mute) + self.hass.async_add_job(self.async_update_ha_state()) + + @asyncio.coroutine + def async_set_volume_level(self, volume): + """Set the volume level.""" + yield from self._group.set_volume(round(volume * 100)) + self.hass.async_add_job(self.async_update_ha_state()) + + def snapshot(self): + """Snapshot the group state.""" + self._group.snapshot() + + @asyncio.coroutine + def async_restore(self): + """Restore the group state.""" + yield from self._group.restore() -class SnapcastDevice(MediaPlayerDevice): +class SnapcastClientDevice(MediaPlayerDevice): """Representation of a Snapcast client device.""" def __init__(self, client): - """Initialize the Snapcast device.""" + """Initialize the Snapcast client device.""" + client.set_callback(self.schedule_update_ha_state) self._client = client @property def name(self): """Return the name of the device.""" - return self._client.identifier + return '{}{}'.format(CLIENT_PREFIX, self._client.identifier) @property def volume_level(self): @@ -74,39 +209,45 @@ class SnapcastDevice(MediaPlayerDevice): @property def supported_features(self): """Flag media player features that are supported.""" - return SUPPORT_SNAPCAST + return SUPPORT_SNAPCAST_CLIENT @property def state(self): """Return the state of the player.""" - if not self._client.connected: - return STATE_OFF + if self._client.connected: + return STATE_ON + return STATE_OFF + + @property + def device_state_attributes(self): + """Return the state attributes.""" + name = '{} {}'.format(self._client.friendly_name, CLIENT_SUFFIX) return { - 'idle': STATE_IDLE, - 'playing': STATE_PLAYING, - 'unknown': STATE_UNKNOWN, - }.get(self._client.stream.status, STATE_UNKNOWN) + 'friendly_name': name + } @property - def source(self): - """Return the current input source.""" - return self._client.stream.name + def should_poll(self): + """Do not poll for state.""" + return False - @property - def source_list(self): - """List of available input sources.""" - return list(self._client.streams_by_name().keys()) - - def mute_volume(self, mute): + @asyncio.coroutine + def async_mute_volume(self, mute): """Send the mute command.""" - self._client.muted = mute + yield from self._client.set_muted(mute) + self.hass.async_add_job(self.async_update_ha_state()) - def set_volume_level(self, volume): + @asyncio.coroutine + def async_set_volume_level(self, volume): """Set the volume level.""" - self._client.volume = round(volume * 100) + yield from self._client.set_volume(round(volume * 100)) + self.hass.async_add_job(self.async_update_ha_state()) - def select_source(self, source): - """Set input source.""" - streams = self._client.streams_by_name() - if source in streams: - self._client.stream = streams[source].identifier + def snapshot(self): + """Snapshot the client state.""" + self._client.snapshot() + + @asyncio.coroutine + def async_restore(self): + """Restore the client state.""" + yield from self._client.restore() diff --git a/requirements_all.txt b/requirements_all.txt index 074fd989ebd..0b6cfcc8c72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -784,7 +784,7 @@ sleepyq==0.6 # smbus-cffi==0.5.1 # homeassistant.components.media_player.snapcast -snapcast==1.2.2 +snapcast==2.0.6 # homeassistant.components.climate.honeywell somecomfort==0.4.1 From 0ccaf979245088c2976d759b5659d039c0426363 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 30 May 2017 11:52:26 +0200 Subject: [PATCH 076/105] Update docstrings and log messages (#7709) --- .../components/telegram_bot/__init__.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index e5b57558989..5693f51c1f4 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -253,7 +253,7 @@ def async_setup(hass, config): for attribute in [ATTR_MESSAGE, ATTR_TITLE, ATTR_URL, ATTR_FILE, ATTR_CAPTION, ATTR_LONGITUDE, ATTR_LATITUDE]: _render_template_attr(kwargs, attribute) - _LOGGER.debug("NEW telegram_message %s: %s", msgtype, kwargs) + _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs) if msgtype == SERVICE_SEND_MESSAGE: yield from hass.async_add_job( @@ -418,22 +418,22 @@ class TelegramNotificationService: 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)", + _LOGGER.debug("Last message 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", + _LOGGER.warning("Update last message: 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.""" + """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", + _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) self._send_msg(self.bot.sendMessage, "Error sending message", @@ -444,13 +444,13 @@ class TelegramNotificationService: 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", + _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.", + _LOGGER.debug("Editing message with ID %s.", message_id or inline_message_id) return self._send_msg(self.bot.editMessageText, "Error editing text message", @@ -472,8 +472,8 @@ class TelegramNotificationService: 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) + _LOGGER.debug("Answer callback query with 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, @@ -493,7 +493,7 @@ class TelegramNotificationService: ) if file_content: for chat_id in self._get_target_chat_ids(target): - _LOGGER.debug("send file to chat_id %s. Caption: %s.", + _LOGGER.debug("Send file to chat ID %s. Caption: %s.", chat_id, caption) self._send_msg(func_send, "Error sending file", chat_id, file_content, @@ -508,7 +508,7 @@ class TelegramNotificationService: 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.", + _LOGGER.debug("Send location %s/%s to chat ID %s.", latitude, longitude, chat_id) self._send_msg(self.bot.sendLocation, "Error sending location", @@ -575,7 +575,6 @@ class BaseTelegramBotEntity: event_data[ATTR_TEXT] = data['text'] event = EVENT_TELEGRAM_TEXT else: - # Some other thing... _LOGGER.warning("Message without text data received: %s", data) event_data[ATTR_TEXT] = str(data) event = EVENT_TELEGRAM_TEXT @@ -597,6 +596,5 @@ class BaseTelegramBotEntity: self.hass.bus.async_fire(event, event_data) return True else: - # Some other thing... - _LOGGER.warning("SOME OTHER THING RECEIVED --> %s", data) + _LOGGER.warning("Message with unknown data received: %s", data) return True From 052cd3fc53505e25896b31e04a5fab1e77ebe8f9 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 30 May 2017 18:26:26 +0200 Subject: [PATCH 077/105] Upgrade PyMVGLive to 1.1.4 (#7832) --- homeassistant/components/sensor/mvglive.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/mvglive.py b/homeassistant/components/sensor/mvglive.py index 3bb027c6e7e..d1f101fc02f 100644 --- a/homeassistant/components/sensor/mvglive.py +++ b/homeassistant/components/sensor/mvglive.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_NAME, ATTR_ATTRIBUTION, STATE_UNKNOWN ) -REQUIREMENTS = ['PyMVGLive==1.1.3'] +REQUIREMENTS = ['PyMVGLive==1.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 0b6cfcc8c72..df8582b701e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -24,7 +24,7 @@ PyISY==1.0.7 PyJWT==1.4.2 # homeassistant.components.sensor.mvglive -PyMVGLive==1.1.3 +PyMVGLive==1.1.4 # homeassistant.components.arduino PyMata==2.14 From 8ee32a8fbd3b476abf4801e93e6157bd35f380ad Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Tue, 30 May 2017 17:17:32 -0400 Subject: [PATCH 078/105] Added persistent error message if cover.myq fails to load (#7700) * Show persistent error if cover.myq fails * Fixed typo on getLogger() * Added ValueError on except condition * Make pylint happy * Removed DEFAULT_ENTITY_NAMESPACE since it is not being used --- homeassistant/components/cover/myq.py | 41 +++++++++++++++++---------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index 06ec7ca6211..5c79540a249 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -12,19 +12,25 @@ from homeassistant.components.cover import CoverDevice from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, CONF_TYPE, STATE_CLOSED) import homeassistant.helpers.config_validation as cv +import homeassistant.loader as loader REQUIREMENTS = [ 'https://github.com/arraylabs/pymyq/archive/v0.0.8.zip' '#pymyq==0.0.8'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'myq' + +NOTIFICATION_ID = 'myq_notification' +NOTIFICATION_TITLE = 'MyQ Cover Setup' + COVER_SCHEMA = vol.Schema({ vol.Required(CONF_TYPE): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string }) -DEFAULT_NAME = 'myq' - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MyQ component.""" @@ -33,23 +39,28 @@ def setup_platform(hass, config, add_devices, discovery_info=None): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) brand = config.get(CONF_TYPE) - - logger = logging.getLogger(__name__) - + persistent_notification = loader.get_component('persistent_notification') myq = pymyq(username, password, brand) - if not myq.is_supported_brand(): - logger.error("Unsupported type. See documentation") - return - - if not myq.is_login_valid(): - logger.error("Username or Password is incorrect") - return - try: + if not myq.is_supported_brand(): + raise ValueError("Unsupported type. See documentation") + + if not myq.is_login_valid(): + raise ValueError("Username or Password is incorrect") + add_devices(MyQDevice(myq, door) for door in myq.get_garage_doors()) - except (TypeError, KeyError, NameError) as ex: - logger.error("%s", ex) + return True + + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + persistent_notification.create( + hass, 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False class MyQDevice(CoverDevice): From 9f5bfe28d15d6167964c69f9efde239b88ff3915 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 30 May 2017 21:34:40 -0700 Subject: [PATCH 079/105] Add initial benchmark framework (#7827) * Add initial benchmark framework * Use timer from timeit --- homeassistant/scripts/benchmark/__init__.py | 74 +++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 homeassistant/scripts/benchmark/__init__.py diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py new file mode 100644 index 00000000000..73ad8bc0cd2 --- /dev/null +++ b/homeassistant/scripts/benchmark/__init__.py @@ -0,0 +1,74 @@ +"""Script to run benchmarks.""" +import asyncio +import argparse +from contextlib import suppress +import logging +from timeit import default_timer as timer + +from homeassistant import core + +BENCHMARKS = {} + + +def run(args): + """Handle ensure config commandline script.""" + # Disable logging + logging.getLogger('homeassistant.core').setLevel(logging.CRITICAL) + + parser = argparse.ArgumentParser( + description=("Run a Home Assistant benchmark.")) + parser.add_argument('name', choices=BENCHMARKS) + parser.add_argument('--script', choices=['benchmark']) + + args = parser.parse_args() + + bench = BENCHMARKS[args.name] + + print('Using event loop:', asyncio.get_event_loop_policy().__module__) + + with suppress(KeyboardInterrupt): + while True: + loop = asyncio.new_event_loop() + hass = core.HomeAssistant(loop) + hass.async_stop_track_tasks() + runtime = loop.run_until_complete(bench(hass)) + print('Benchmark {} done in {}s'.format(bench.__name__, runtime)) + loop.run_until_complete(hass.async_stop()) + loop.close() + + return 0 + + +def benchmark(func): + """Decorator to mark a benchmark.""" + BENCHMARKS[func.__name__] = func + return func + + +@benchmark +@asyncio.coroutine +def async_million_events(hass): + """Run a million events.""" + count = 0 + event_name = 'benchmark_event' + event = asyncio.Event(loop=hass.loop) + + @core.callback + def listener(_): + """Handle event.""" + nonlocal count + count += 1 + + if count == 10**6: + event.set() + + hass.bus.async_listen(event_name, listener) + + start = timer() + + for _ in range(10**6): + hass.bus.async_fire(event_name) + + yield from event.wait() + + return timer() - start From bb92ef5497c26fc6e9b7edf17cb1f06e25ca2657 Mon Sep 17 00:00:00 2001 From: Phil Hawthorne Date: Wed, 31 May 2017 16:56:20 +1000 Subject: [PATCH 080/105] Downgrade Docker to Python 3.5 to solve Segmentation Faults (#7799) Downgrades the Dockerfiles used by Home Assistant to Python 3.5, after Python 3.6 base image was causing segmentation faults. See home-assistant/home-assistant#7752 --- Dockerfile | 2 +- virtualization/Docker/Dockerfile.dev | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8c4cd0f5440..3d899f2404b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6 +FROM python:3.5 MAINTAINER Paulus Schoutsen # Uncomment any of the following lines to disable the installation. diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 2f40ea5f409..58d99285bc9 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.6 +FROM python:3.5 MAINTAINER Paulus Schoutsen # Uncomment any of the following lines to disable the installation. From 9762e1613d7c2eb2a1cedcf923460321ae3f6d0c Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Wed, 31 May 2017 03:25:25 -0400 Subject: [PATCH 081/105] Introduced support to Netgear Arlo Cameras (#7826) * Introduced support to Netgear Arlo Cameras * Using async_setup_platform() and applied other changes * Removed unecessary variables * Using asyncio for sensor/arlo * Update arlo.py * Removed entity_namespace --- .coveragerc | 3 + homeassistant/components/arlo.py | 60 +++++++++++ homeassistant/components/camera/arlo.py | 92 +++++++++++++++++ homeassistant/components/sensor/arlo.py | 126 ++++++++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 284 insertions(+) create mode 100644 homeassistant/components/arlo.py create mode 100644 homeassistant/components/camera/arlo.py create mode 100644 homeassistant/components/sensor/arlo.py diff --git a/.coveragerc b/.coveragerc index b02b4d12579..3163b5f723c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -20,6 +20,9 @@ omit = homeassistant/components/android_ip_webcam.py homeassistant/components/*/android_ip_webcam.py + homeassistant/components/arlo.py + homeassistant/components/*/arlo.py + homeassistant/components/axis.py homeassistant/components/*/axis.py diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py new file mode 100644 index 00000000000..feb77209237 --- /dev/null +++ b/homeassistant/components/arlo.py @@ -0,0 +1,60 @@ +""" +This component provides basic support for Netgear Arlo IP cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/arlo/ +""" +import logging +import voluptuous as vol +from homeassistant.helpers import config_validation as cv + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +import homeassistant.loader as loader + +from requests.exceptions import HTTPError, ConnectTimeout + +REQUIREMENTS = ['pyarlo==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = 'Data provided by arlo.netgear.com' + +DOMAIN = 'arlo' + +DEFAULT_BRAND = 'Netgear Arlo' + +NOTIFICATION_ID = 'arlo_notification' +NOTIFICATION_TITLE = 'Arlo Camera Setup' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up an Arlo component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + + persistent_notification = loader.get_component('persistent_notification') + try: + from pyarlo import PyArlo + + arlo = PyArlo(username, password, preload=False) + if not arlo.is_connected: + return False + hass.data['arlo'] = arlo + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Netgar Arlo: %s", str(ex)) + persistent_notification.create( + hass, 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + return True diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py new file mode 100644 index 00000000000..16688370b07 --- /dev/null +++ b/homeassistant/components/camera/arlo.py @@ -0,0 +1,92 @@ +""" +This component provides basic support for Netgear Arlo IP cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.arlo/ +""" +import asyncio +import logging +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.components.arlo import DEFAULT_BRAND + +from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.helpers.aiohttp_client import ( + async_aiohttp_proxy_stream) + +DEPENDENCIES = ['arlo', 'ffmpeg'] + +_LOGGER = logging.getLogger(__name__) + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FFMPEG_ARGUMENTS): + cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up an Arlo IP Camera.""" + arlo = hass.data.get('arlo') + if not arlo: + return False + + cameras = [] + for camera in arlo.cameras: + cameras.append(ArloCam(hass, camera, config)) + + async_add_devices(cameras, True) + return True + + +class ArloCam(Camera): + """An implementation of a Netgear Arlo IP camera.""" + + def __init__(self, hass, camera, device_info): + """Initialize an Arlo camera.""" + super().__init__() + + self._camera = camera + self._name = self._camera.name + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + + def camera_image(self): + """Return a still image reponse from the camera.""" + return self._camera.last_image + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + video = self._camera.last_video + if not video: + return + + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + yield from stream.open_camera( + video.video_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): + """Return the name of this camera.""" + return self._name + + @property + def model(self): + """Camera model.""" + return self._camera.model_id + + @property + def brand(self): + """Camera brand.""" + return DEFAULT_BRAND diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py new file mode 100644 index 00000000000..e4e0d0330f6 --- /dev/null +++ b/homeassistant/components/sensor/arlo.py @@ -0,0 +1,126 @@ +""" +This component provides HA sensor for Netgear Arlo IP cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.arlo/ +""" +import asyncio +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.components.arlo import ( + CONF_ATTRIBUTION, DEFAULT_BRAND) + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['arlo'] + +_LOGGER = logging.getLogger(__name__) + +# sensor_type [ description, unit, icon ] +SENSOR_TYPES = { + 'last_capture': ['Last', None, 'run-fast'], + 'total_cameras': ['Arlo Cameras', None, 'video'], + 'captured_today': ['Captured Today', None, 'file-video'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + +SCAN_INTERVAL = timedelta(seconds=90) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up an Arlo IP sensor.""" + arlo = hass.data.get('arlo') + if not arlo: + return False + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type == 'total_cameras': + sensors.append(ArloSensor(hass, + SENSOR_TYPES[sensor_type][0], + arlo, + sensor_type)) + else: + for camera in arlo.cameras: + name = '{0} {1}'.format(SENSOR_TYPES[sensor_type][0], + camera.name) + sensors.append(ArloSensor(hass, name, camera, sensor_type)) + + async_add_devices(sensors, True) + return True + + +class ArloSensor(Entity): + """An implementation of a Netgear Arlo IP sensor.""" + + def __init__(self, hass, name, device, sensor_type): + """Initialize an Arlo sensor.""" + super().__init__() + self._name = name + self._hass = hass + self._data = device + self._sensor_type = sensor_type + self._state = None + self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2]) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES.get(self._sensor_type)[1] + + def update(self): + """Get the latest data and updates the state.""" + self._data.update() + + if self._sensor_type == 'total_cameras': + self._state = len(self._data.cameras) + + elif self._sensor_type == 'captured_today': + self._state = len(self._data.captured_today) + + elif self._sensor_type == 'last_capture': + try: + video = self._data.videos()[0] + self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") + except (AttributeError, IndexError): + self._state = STATE_UNKNOWN + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + attrs['brand'] = DEFAULT_BRAND + + if self._sensor_type == 'last_capture' or \ + self._sensor_type == 'captured_today': + attrs['model'] = self._data.model_id + + return attrs diff --git a/requirements_all.txt b/requirements_all.txt index df8582b701e..68d7fd5aae6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -489,6 +489,9 @@ pyRFXtrx==0.18.0 # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 +# homeassistant.components.arlo +pyarlo==0.0.4 + # homeassistant.components.notify.xmpp pyasn1-modules==0.0.8 From 66d6f5174d5be486d0cb703a83f549459acff8c7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 31 May 2017 18:08:53 +0200 Subject: [PATCH 082/105] Allow 'base_url' (fixes #7784) (#7796) --- homeassistant/components/upnp.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 3bd7d4dacc6..e6ad66d0b51 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -1,10 +1,11 @@ """ This module will attempt to open a port in your router for Home Assistant. -For more details about UPnP, please refer to the documentation at +For more details about this component, please refer to the documentation at https://home-assistant.io/components/upnp/ """ import logging +from urllib.parse import urlsplit import voluptuous as vol @@ -32,11 +33,15 @@ def setup(hass, config): try: upnp.selectigd() except Exception: - _LOGGER.exception("Error when attempting to discover a UPnP IGD") + _LOGGER.exception("Error when attempting to discover an UPnP IGD") return False - upnp.addportmapping(hass.config.api.port, 'TCP', hass.config.api.host, - hass.config.api.port, 'Home Assistant', '') + base_url = urlsplit(hass.config.api.base_url) + host = base_url.hostname + external_port = internal_port = base_url.port + + upnp.addportmapping( + external_port, 'TCP', host, internal_port, 'Home Assistant', '') def deregister_port(event): """De-register the UPnP port mapping.""" From e0712ba3295f5cead620a5cd821783d466f1b5de Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 2 Jun 2017 01:33:16 -0400 Subject: [PATCH 083/105] Expose the node name on the zwave node entity (#7787) --- homeassistant/components/zwave/node_entity.py | 2 ++ tests/components/zwave/test_node_entity.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 19527e59792..5a441114f55 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -17,6 +17,7 @@ ATTR_READY = 'is_ready' ATTR_FAILED = 'is_failed' ATTR_PRODUCT_NAME = 'product_name' ATTR_MANUFACTURER_NAME = 'manufacturer_name' +ATTR_NODE_NAME = 'node_name' STAGE_COMPLETE = 'Complete' @@ -165,6 +166,7 @@ class ZWaveNodeEntity(ZWaveBaseEntity): """Return the device specific state attributes.""" attrs = { ATTR_NODE_ID: self.node_id, + ATTR_NODE_NAME: self._name, ATTR_MANUFACTURER_NAME: self._manufacturer_name, ATTR_PRODUCT_NAME: self._product_name, } diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 871520d1e6d..73e8e163096 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -83,6 +83,7 @@ class TestZWaveNodeEntity(unittest.TestCase): self.maxDiff = None self.assertEqual( {'node_id': self.node.node_id, + 'node_name': 'Mock Node', 'manufacturer_name': 'Test Manufacturer', 'product_name': 'Test Product'}, self.entity.device_state_attributes) @@ -140,6 +141,7 @@ class TestZWaveNodeEntity(unittest.TestCase): self.entity.node_changed() self.assertEqual( {'node_id': self.node.node_id, + 'node_name': 'Mock Node', 'manufacturer_name': 'Test Manufacturer', 'product_name': 'Test Product', 'query_stage': 'Dynamic', From 4bcbeef480715a88a66a39a39ff516f6d64ed134 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Fri, 2 Jun 2017 07:33:53 +0200 Subject: [PATCH 084/105] Bumped pyhomematic version (#7861) --- 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 0ba5a814993..07627a84e4e 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.26'] +REQUIREMENTS = ['pyhomematic==0.1.27'] DOMAIN = 'homematic' diff --git a/requirements_all.txt b/requirements_all.txt index 68d7fd5aae6..f0e0884083c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -551,7 +551,7 @@ pyharmony==1.0.16 pyhik==0.1.2 # homeassistant.components.homematic -pyhomematic==0.1.26 +pyhomematic==0.1.27 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.1.0 From cf42303afb63d72a458892aa38708e868653e717 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 2 Jun 2017 01:40:27 -0400 Subject: [PATCH 085/105] Rename time trigger 'after' to 'at' (#7846) --- homeassistant/components/automation/time.py | 16 +++++++++++----- homeassistant/const.py | 1 + tests/components/automation/test_time.py | 10 +++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 1ca714026a2..8ba082e3331 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -10,7 +10,7 @@ import logging import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import CONF_AFTER, CONF_PLATFORM +from homeassistant.const import CONF_AT, CONF_PLATFORM, CONF_AFTER from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_change @@ -22,20 +22,26 @@ _LOGGER = logging.getLogger(__name__) TRIGGER_SCHEMA = vol.All(vol.Schema({ vol.Required(CONF_PLATFORM): 'time', + CONF_AT: cv.time, CONF_AFTER: cv.time, CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)), CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)), CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)), }), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, - CONF_SECONDS, CONF_AFTER)) + CONF_SECONDS, CONF_AT, CONF_AFTER)) @asyncio.coroutine def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" - if CONF_AFTER in config: - after = config.get(CONF_AFTER) - hours, minutes, seconds = after.hour, after.minute, after.second + if CONF_AT in config: + at_time = config.get(CONF_AT) + hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second + elif CONF_AFTER in config: + _LOGGER.warning("'after' is deprecated for the time trigger. Please " + "rename 'after' to 'at' in your configuration file.") + at_time = config.get(CONF_AFTER) + hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second else: hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2e886a992a2..18c73bdb9b7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -59,6 +59,7 @@ CONF_ACCESS_TOKEN = 'access_token' CONF_AFTER = 'after' CONF_ALIAS = 'alias' CONF_API_KEY = 'api_key' +CONF_AT = 'at' CONF_AUTHENTICATION = 'authentication' CONF_BASE = 'base' CONF_BEFORE = 'before' diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 3489699d588..5d5d5ea29ec 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -179,13 +179,13 @@ class TestAutomationTime(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) - def test_if_fires_using_after(self): - """Test for firing after.""" + def test_if_fires_using_at(self): + """Test for firing at.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'time', - 'after': '5:00:00', + 'at': '5:00:00', }, 'action': { 'service': 'test.automation', @@ -224,7 +224,7 @@ class TestAutomationTime(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(self.calls)) - def test_if_not_fires_using_wrong_after(self): + def test_if_not_fires_using_wrong_at(self): """YAML translates time values to total seconds. This should break the before rule. @@ -234,7 +234,7 @@ class TestAutomationTime(unittest.TestCase): automation.DOMAIN: { 'trigger': { 'platform': 'time', - 'after': 3605, + 'at': 3605, # Total seconds. Hour = 3600 second }, 'action': { From beb8c05d91276564406b60d520b3c191443559fa Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 2 Jun 2017 01:43:24 -0400 Subject: [PATCH 086/105] Use expected behvaior for above/below (#7857) --- homeassistant/helpers/condition.py | 4 +- .../automation/test_numeric_state.py | 58 +++++++++++++++++-- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index a0753b0f766..87b84a80815 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -166,10 +166,10 @@ def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None, _LOGGER.warning("Value cannot be processed as a number: %s", value) return False - if below is not None and value > below: + if below is not None and value >= below: return False - if above is not None and value < above: + if above is not None and value <= above: return False return True diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 0fca1d96a69..355e26abf9b 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -102,6 +102,29 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(self.calls)) + def test_if_not_below_fires_on_entity_change_to_equal(self): + """"Test the firing with changed entity.""" + self.hass.states.set('test.entity', 11) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + # 10 is not below 10 so this should not fire again + self.hass.states.set('test.entity', 10) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + def test_if_fires_on_entity_change_above(self): """"Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { @@ -169,6 +192,30 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(self.calls)) + def test_if_not_above_fires_on_entity_change_to_equal(self): + """"Test the firing with changed entity.""" + # set initial state + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + # 10 is not above 10 so this should not fire again + self.hass.states.set('test.entity', 10) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + def test_if_fires_on_entity_change_below_range(self): """"Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { @@ -494,7 +541,6 @@ class TestAutomationNumericState(unittest.TestCase): def test_if_action(self): """"Test if action.""" entity_id = 'domain.test_entity' - test_state = 10 assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -504,8 +550,8 @@ class TestAutomationNumericState(unittest.TestCase): 'condition': { 'condition': 'numeric_state', 'entity_id': entity_id, - 'above': test_state, - 'below': test_state + 2 + 'above': 8, + 'below': 12, }, 'action': { 'service': 'test.automation' @@ -513,19 +559,19 @@ class TestAutomationNumericState(unittest.TestCase): } }) - self.hass.states.set(entity_id, test_state) + self.hass.states.set(entity_id, 10) self.hass.bus.fire('test_event') self.hass.block_till_done() self.assertEqual(1, len(self.calls)) - self.hass.states.set(entity_id, test_state - 1) + self.hass.states.set(entity_id, 8) self.hass.bus.fire('test_event') self.hass.block_till_done() self.assertEqual(1, len(self.calls)) - self.hass.states.set(entity_id, test_state + 1) + self.hass.states.set(entity_id, 9) self.hass.bus.fire('test_event') self.hass.block_till_done() From 2065426b16da509c6fc190d105be4d0cb02e50c9 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Fri, 2 Jun 2017 07:44:44 +0200 Subject: [PATCH 087/105] log time delay of domain setup in info level (#7808) * log time delay of domain setup in info level * when setup problems appear, it's difficult to debug which are the components that took a lot to set up. This minimal change goes further than the 'slow setup warning' and measures the setup time interval for each domain. * use timer as in helpers/entity --- homeassistant/setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index e3a520fff0e..285a5755145 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -3,6 +3,7 @@ import asyncio import logging import logging.handlers import os +from timeit import default_timer as timer from types import ModuleType from typing import Optional, Dict @@ -175,6 +176,7 @@ def _async_setup_component(hass: core.HomeAssistant, async_comp = hasattr(component, 'async_setup') + start = timer() _LOGGER.info("Setting up %s", domain) warn_task = hass.loop.call_later( SLOW_SETUP_WARNING, _LOGGER.warning, @@ -191,7 +193,9 @@ def _async_setup_component(hass: core.HomeAssistant, async_notify_setup_error(hass, domain, True) return False finally: + end = timer() warn_task.cancel() + _LOGGER.info("Setup of domain %s took %.1f seconds.", domain, end - start) if result is False: log_error("Component failed to initialize.") From 1b5f6aa1b95ff4b895538372a550fdb1ab49a027 Mon Sep 17 00:00:00 2001 From: Boris K Date: Fri, 2 Jun 2017 07:52:55 +0200 Subject: [PATCH 088/105] Optimize history_stats efficiency and database usage (#7858) --- .../components/sensor/history_stats.py | 40 ++++++++++++------- tests/components/sensor/test_history_stats.py | 29 ++++++++++---- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index 7d9dfaaa48f..08546e3f0c8 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -44,8 +44,6 @@ UNITS = { } ICON = 'mdi:chart-line' -ATTR_START = 'from' -ATTR_END = 'to' ATTR_VALUE = 'value' @@ -157,12 +155,9 @@ class HistoryStatsSensor(Entity): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - start, end = self._period hsh = HistoryStatsHelper return { ATTR_VALUE: hsh.pretty_duration(self.value), - ATTR_START: start.strftime('%Y-%m-%d %H:%M:%S'), - ATTR_END: end.strftime('%Y-%m-%d %H:%M:%S'), } @property @@ -172,13 +167,33 @@ class HistoryStatsSensor(Entity): def update(self): """Get the latest data and updates the states.""" + # Get previous values of start and end + p_start, p_end = self._period + # Parse templates self.update_period() start, end = self._period - # Convert to UTC + # Convert times to UTC start = dt_util.as_utc(start) end = dt_util.as_utc(end) + p_start = dt_util.as_utc(p_start) + p_end = dt_util.as_utc(p_end) + now = dt_util.as_utc(datetime.datetime.now()) + + # Compute integer timestamps + start_timestamp = math.floor(dt_util.as_timestamp(start)) + end_timestamp = math.floor(dt_util.as_timestamp(end)) + p_start_timestamp = math.floor(dt_util.as_timestamp(p_start)) + p_end_timestamp = math.floor(dt_util.as_timestamp(p_end)) + now_timestamp = math.floor(dt_util.as_timestamp(now)) + + # If period has not changed and current time after the period end... + if start_timestamp == p_start_timestamp and \ + end_timestamp == p_end_timestamp and \ + end_timestamp <= now_timestamp: + # Don't compute anything as the value cannot have changed + return # Get history between start and end history_list = history.state_changes_during_period( @@ -191,7 +206,7 @@ class HistoryStatsSensor(Entity): last_state = history.get_state(self.hass, start, self._entity_id) last_state = (last_state is not None and last_state == self._entity_state) - last_time = dt_util.as_timestamp(start) + last_time = start_timestamp elapsed = 0 count = 0 @@ -210,8 +225,7 @@ class HistoryStatsSensor(Entity): # Count time elapsed between last history state and end of measure if last_state: - measure_end = min(dt_util.as_timestamp(end), dt_util.as_timestamp( - datetime.datetime.now())) + measure_end = min(end_timestamp, now_timestamp) elapsed += measure_end - last_time # Save value in hours @@ -279,13 +293,11 @@ class HistoryStatsHelper: hours, seconds = divmod(seconds, 3600) minutes, seconds = divmod(seconds, 60) if days > 0: - return '%dd %dh %dm %ds' % (days, hours, minutes, seconds) + return '%dd %dh %dm' % (days, hours, minutes) elif hours > 0: - return '%dh %dm %ds' % (hours, minutes, seconds) - elif minutes > 0: - return '%dm %ds' % (minutes, seconds) + return '%dh %dm' % (hours, minutes) else: - return '%ds' % (seconds,) + return '%dm' % minutes @staticmethod def pretty_ratio(value, period): diff --git a/tests/components/sensor/test_history_stats.py b/tests/components/sensor/test_history_stats.py index 282991fba0b..7fdf732825b 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -58,16 +58,29 @@ class TestHistoryStatsSensor(unittest.TestCase): self.hass, 'test', 'on', None, today, duration, 'time', 'test') sensor1.update_period() + sensor1_start, sensor1_end = sensor1._period sensor2.update_period() + sensor2_start, sensor2_end = sensor2._period - self.assertEqual( - sensor1.device_state_attributes['from'][-8:], '00:00:00') - self.assertEqual( - sensor1.device_state_attributes['to'][-8:], '02:01:00') - self.assertEqual( - sensor2.device_state_attributes['from'][-8:], '21:59:00') - self.assertEqual( - sensor2.device_state_attributes['to'][-8:], '00:00:00') + # Start = 00:00:00 + self.assertEqual(sensor1_start.hour, 0) + self.assertEqual(sensor1_start.minute, 0) + self.assertEqual(sensor1_start.second, 0) + + # End = 02:01:00 + self.assertEqual(sensor1_end.hour, 2) + self.assertEqual(sensor1_end.minute, 1) + self.assertEqual(sensor1_end.second, 0) + + # Start = 21:59:00 + self.assertEqual(sensor2_start.hour, 21) + self.assertEqual(sensor2_start.minute, 59) + self.assertEqual(sensor2_start.second, 0) + + # End = 00:00:00 + self.assertEqual(sensor2_end.hour, 0) + self.assertEqual(sensor2_end.minute, 0) + self.assertEqual(sensor2_end.second, 0) def test_measure(self): """Test the history statistics sensor measure.""" From 9480f4121086e21fe70d4832c98100d5929cfd00 Mon Sep 17 00:00:00 2001 From: abmantis Date: Fri, 2 Jun 2017 06:58:57 +0100 Subject: [PATCH 089/105] dont use default for switch name, so that the object id is used (#7845) --- homeassistant/components/switch/broadlink.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 9de81fb9402..ffa4aaea615 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -43,7 +43,7 @@ SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES SWITCH_SCHEMA = vol.Schema({ vol.Optional(CONF_COMMAND_OFF, default=None): cv.string, vol.Optional(CONF_COMMAND_ON, default=None): cv.string, - vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ From e2cfdbff06931c084c95e14a91740e60e4f6e1bc Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 2 Jun 2017 08:05:05 +0200 Subject: [PATCH 090/105] Disallow ambiguous color descriptors in the light.turn_on schema (#7765) * Disallow ambiguous color descriptors in the light.turn_on schema * Update tests --- homeassistant/components/light/__init__.py | 22 +++++++++++++------- tests/components/light/test_init.py | 13 +++++++----- tests/components/light/test_mqtt.py | 4 +++- tests/components/light/test_mqtt_json.py | 6 +----- tests/components/light/test_mqtt_template.py | 18 +++++++++++----- 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 2065513630b..30a05215698 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -77,6 +77,8 @@ EFFECT_COLORLOOP = "colorloop" EFFECT_RANDOM = "random" EFFECT_WHITE = "white" +COLOR_GROUP = "Color descriptors" + LIGHT_PROFILES_FILE = "light_profiles.csv" PROP_TO_ATTR = { @@ -98,17 +100,21 @@ 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, + vol.Exclusive(ATTR_PROFILE, COLOR_GROUP): 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)), + vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, + vol.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): + vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), + vol.Coerce(tuple)), + vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): + vol.All(vol.ExactSequence((cv.small_float, cv.small_float)), + vol.Coerce(tuple)), + vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): + 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, diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index d024df20629..ecfe3f36761 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -206,10 +206,10 @@ class TestLight(unittest.TestCase): # Test light profiles light.turn_on(self.hass, dev1.entity_id, profile=prof_name) - # Specify a profile and attributes to overwrite it + # Specify a profile and a brightness attribute to overwrite it light.turn_on( self.hass, dev2.entity_id, - profile=prof_name, brightness=100, xy_color=(.4, .6)) + profile=prof_name, brightness=100) self.hass.block_till_done() @@ -222,10 +222,10 @@ class TestLight(unittest.TestCase): _, data = dev2.last_call('turn_on') self.assertEqual( {light.ATTR_BRIGHTNESS: 100, - light.ATTR_XY_COLOR: (.4, .6)}, + light.ATTR_XY_COLOR: (.5119, .4147)}, data) - # Test shitty data + # Test bad data light.turn_on(self.hass) light.turn_on(self.hass, dev1.entity_id, profile="nonexisting") light.turn_on(self.hass, dev2.entity_id, xy_color=["bla-di-bla", 5]) @@ -245,7 +245,10 @@ class TestLight(unittest.TestCase): # faulty attributes will not trigger a service call light.turn_on( self.hass, dev1.entity_id, - profile=prof_name, brightness='bright', rgb_color='yellowish') + profile=prof_name, brightness='bright') + light.turn_on( + self.hass, dev1.entity_id, + rgb_color='yellowish') light.turn_on( self.hass, dev2.entity_id, white_value='high') diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 7ef29ff0d8d..41df5f0d48c 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -491,8 +491,10 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(STATE_OFF, state.state) self.mock_publish.reset_mock() + light.turn_on(self.hass, 'light.test', + brightness=50, xy_color=[0.123, 0.123]) light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75], - brightness=50, white_value=80, xy_color=[0.123, 0.123]) + white_value=80) self.hass.block_till_done() self.mock_publish().async_publish.assert_has_calls([ diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index 8f46ef294c4..172eb8ca178 100755 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -294,7 +294,7 @@ class TestLightMQTTJSON(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_OFF, state.state) - light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75], + light.turn_on(self.hass, 'light.test', brightness=50, color_temp=155, effect='colorloop', white_value=170) self.hass.block_till_done() @@ -308,15 +308,11 @@ class TestLightMQTTJSON(unittest.TestCase): self.assertEqual(50, message_json["brightness"]) self.assertEqual(155, message_json["color_temp"]) self.assertEqual('colorloop', message_json["effect"]) - self.assertEqual(75, message_json["color"]["r"]) - self.assertEqual(75, message_json["color"]["g"]) - self.assertEqual(75, message_json["color"]["b"]) self.assertEqual(170, message_json["white_value"]) self.assertEqual("ON", message_json["state"]) state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual((75, 75, 75), state.attributes['rgb_color']) self.assertEqual(50, state.attributes['brightness']) self.assertEqual(155, state.attributes['color_temp']) self.assertEqual('colorloop', state.attributes['effect']) diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index b16e0dc84e4..3c11d11326d 100755 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -245,19 +245,27 @@ class TestLightMQTTTemplate(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_OFF, state.state) - # turn on the light with brightness, color, color temp and white val + # turn on the light with brightness, color light.turn_on(self.hass, 'light.test', brightness=50, - rgb_color=[75, 75, 75], color_temp=200, white_value=139) + rgb_color=[75, 75, 75]) self.hass.block_till_done() self.assertEqual('test_light_rgb/set', self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(2, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) # check the payload payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,50,200,139,75-75-75', payload) + self.assertEqual('on,50,,,75-75-75', payload) + + # turn on the light with color temp and white val + light.turn_on(self.hass, 'light.test', color_temp=200, white_value=139) + self.hass.block_till_done() + + payload = self.mock_publish.mock_calls[-2][1][1] + self.assertEqual('on,,200,139,--', payload) + + self.assertEqual(2, self.mock_publish.mock_calls[-2][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) # check the state state = self.hass.states.get('light.test') From d0021a61711d163f0cf70e0a0d179c6fa384d32f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 1 Jun 2017 23:23:39 -0700 Subject: [PATCH 091/105] Make monkey patch work in Python 3.6 (#7848) * Make monkey patch work in Python 3.6 * Update dockerfiles back to 3.6 * Lint * Do not set env variable for dockerfile * Lint --- Dockerfile | 2 +- homeassistant/__main__.py | 12 ++++- homeassistant/monkey_patch.py | 75 ++++++++++++++++++++++++++++ script/bootstrap_server | 2 +- virtualization/Docker/Dockerfile.dev | 2 +- 5 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 homeassistant/monkey_patch.py diff --git a/Dockerfile b/Dockerfile index 3d899f2404b..8c4cd0f5440 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. diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 0b07e5aa6f6..219d413db12 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -10,6 +10,7 @@ import threading from typing import Optional, List +from homeassistant import monkey_patch from homeassistant.const import ( __version__, EVENT_HOMEASSISTANT_START, @@ -17,7 +18,6 @@ from homeassistant.const import ( REQUIRED_PYTHON_VER_WIN, RESTART_EXIT_CODE, ) -from homeassistant.util.async import run_callback_threadsafe def attempt_use_uvloop(): @@ -310,6 +310,9 @@ def setup_and_run_hass(config_dir: str, return None if args.open_ui: + # Imported here to avoid importing asyncio before monkey patch + from homeassistant.util.async import run_callback_threadsafe + def open_browser(event): """Open the webinterface in a browser.""" if hass.config.api is not None: @@ -371,6 +374,13 @@ def main() -> int: """Start Home Assistant.""" validate_python() + if os.environ.get('HASS_MONKEYPATCH_ASYNCIO') == '1': + if sys.version_info[:3] >= (3, 6): + monkey_patch.disable_c_asyncio() + monkey_patch.patch_weakref_tasks() + elif sys.version_info[:3] < (3, 5, 3): + monkey_patch.patch_weakref_tasks() + attempt_use_uvloop() if sys.version_info[:3] < (3, 5, 3): diff --git a/homeassistant/monkey_patch.py b/homeassistant/monkey_patch.py new file mode 100644 index 00000000000..819d8de48e0 --- /dev/null +++ b/homeassistant/monkey_patch.py @@ -0,0 +1,75 @@ +"""Monkey patch Python to work around issues causing segfaults. + +Under heavy threading operations that schedule calls into +the asyncio event loop, Task objects are created. Due to +a bug in Python, GC may have an issue when switching between +the threads and objects with __del__ (which various components +in HASS have). + +This monkey-patch removes the weakref.Weakset, and replaces it +with an object that ignores the only call utilizing it (the +Task.__init__ which calls _all_tasks.add(self)). It also removes +the __del__ which could trigger the future objects __del__ at +unpredictable times. + +The side-effect of this manipulation of the Task is that +Task.all_tasks() is no longer accurate, and there will be no +warning emitted if a Task is GC'd while in use. + +Related Python bugs: + - https://bugs.python.org/issue26617 +""" +import sys + + +def patch_weakref_tasks(): + """Replace weakref.WeakSet to address Python 3 bug.""" + # pylint: disable=no-self-use, protected-access, bare-except + import asyncio.tasks + + class IgnoreCalls: + """Ignore add calls.""" + + def add(self, other): + """No-op add.""" + return + + asyncio.tasks.Task._all_tasks = IgnoreCalls() + try: + del asyncio.tasks.Task.__del__ + except: + pass + + +def disable_c_asyncio(): + """Disable using C implementation of asyncio. + + Required to be able to apply the weakref monkey patch. + + Requires Python 3.6+. + """ + class AsyncioImportFinder: + """Finder that blocks C version of asyncio being loaded.""" + + PATH_TRIGGER = '_asyncio' + + def __init__(self, path_entry): + if path_entry != self.PATH_TRIGGER: + raise ImportError() + return + + def find_module(self, fullname, path=None): + """Find a module.""" + if fullname == self.PATH_TRIGGER: + # We lint in Py34, exception is introduced in Py36 + # pylint: disable=undefined-variable + raise ModuleNotFoundError() # noqa + return None + + sys.path_hooks.append(AsyncioImportFinder) + sys.path.insert(0, AsyncioImportFinder.PATH_TRIGGER) + + try: + import _asyncio # noqa + except ImportError: + pass diff --git a/script/bootstrap_server b/script/bootstrap_server index 7929a00fe55..791adc3a0c1 100755 --- a/script/bootstrap_server +++ b/script/bootstrap_server @@ -7,4 +7,4 @@ set -e cd "$(dirname "$0")/.." echo "Installing test dependencies..." -python3 -m pip install tox +python3 -m pip install tox colorlog diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 58d99285bc9..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. From 3a92bd78ea5e0cd51f695ddf0e63550911bb15ef Mon Sep 17 00:00:00 2001 From: "Craig J. Ward" Date: Fri, 2 Jun 2017 01:36:47 -0500 Subject: [PATCH 092/105] fix permissions issue for Insteon Local #6558 (#7860) * fix unlinked commit * Update insteon_local.py --- homeassistant/components/insteon_local.py | 10 ++++++++-- requirements_all.txt | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/insteon_local.py b/homeassistant/components/insteon_local.py index 1f700b5ff3e..90e146d0e4f 100644 --- a/homeassistant/components/insteon_local.py +++ b/homeassistant/components/insteon_local.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/insteon_local/ """ import logging +import os import requests import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, CONF_HOST, CONF_PORT, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['insteonlocal==0.48'] +REQUIREMENTS = ['insteonlocal==0.52'] _LOGGER = logging.getLogger(__name__) @@ -47,7 +48,12 @@ def setup(hass, config): timeout = conf.get(CONF_TIMEOUT) try: - insteonhub = Hub(host, username, password, port, timeout, _LOGGER) + if not os.path.exists(hass.config.path('.insteon_cache')): + os.makedirs(hass.config.path('.insteon_cache')) + + insteonhub = Hub(host, username, password, port, timeout, _LOGGER, + hass.config.path('.insteon_cache')) + # Check for successful connection insteonhub.get_buffer_status() except requests.exceptions.ConnectTimeout: diff --git a/requirements_all.txt b/requirements_all.txt index f0e0884083c..54d8e04765d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -320,7 +320,7 @@ influxdb==3.0.0 insteon_hub==0.4.5 # homeassistant.components.insteon_local -insteonlocal==0.48 +insteonlocal==0.52 # homeassistant.components.insteon_plm insteonplm==0.7.4 From 78887c5d5cdc48acc6647ca130917956af60f361 Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Thu, 1 Jun 2017 23:50:04 -0700 Subject: [PATCH 093/105] =?UTF-8?q?Start=20of=20migration=20framework,=20t?= =?UTF-8?q?o=20allow=20moving=20of=20files=20in=20the=20config=20=E2=80=A6?= =?UTF-8?q?=20(#7740)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Start of migration framework, to allow moving of files in the config directory to be hidden, ios.conf used as the first one to undergo this change. * Update const.py * Update test_config.py * improvement to syntax --- homeassistant/components/ios.py | 2 +- homeassistant/components/notify/ios.py | 2 +- homeassistant/config.py | 10 ++++++ homeassistant/const.py | 2 +- tests/test_config.py | 50 ++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index 26a462bb2a8..13ccee9df3e 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -167,7 +167,7 @@ IDENTIFY_SCHEMA = vol.Schema({ vol.Optional(ATTR_PUSH_SOUNDS): list }, extra=vol.ALLOW_EXTRA) -CONFIGURATION_FILE = 'ios.conf' +CONFIGURATION_FILE = '.ios.conf' CONFIG_FILE = {ATTR_DEVICES: {}} diff --git a/homeassistant/components/notify/ios.py b/homeassistant/components/notify/ios.py index 469f1f3e61f..8609e1dabee 100644 --- a/homeassistant/components/notify/ios.py +++ b/homeassistant/components/notify/ios.py @@ -85,7 +85,7 @@ class iOSNotificationService(BaseNotificationService): for target in targets: if target not in ios.enabled_push_ids(): - _LOGGER.error("The target (%s) does not exist in ios.conf.", + _LOGGER.error("The target (%s) does not exist in .ios.conf.", targets) return diff --git a/homeassistant/config.py b/homeassistant/config.py index d0d7cedf370..f59bb131c8f 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -36,6 +36,10 @@ VERSION_FILE = '.HA_VERSION' CONFIG_DIR_NAME = '.homeassistant' DATA_CUSTOMIZE = 'hass_customize' +FILE_MIGRATION = [ + ["ios.conf", ".ios.conf"], + ] + DEFAULT_CORE_CONFIG = ( # Tuples (attribute, default, auto detect property, description) (CONF_NAME, 'Home', None, 'Name of the location where Home Assistant is ' @@ -292,6 +296,12 @@ def process_ha_config_upgrade(hass): with open(version_path, 'wt') as outp: outp.write(__version__) + _LOGGER.info('Migrating old system config files to new locations') + for oldf, newf in FILE_MIGRATION: + if os.path.isfile(hass.config.path(oldf)): + _LOGGER.info('Migrating %s to %s', oldf, newf) + os.rename(hass.config.path(oldf), hass.config.path(newf)) + @callback def async_log_exception(ex, domain, config, hass): diff --git a/homeassistant/const.py b/homeassistant/const.py index 18c73bdb9b7..aad46a4fd08 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 46 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0.dev1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) diff --git a/tests/test_config.py b/tests/test_config.py index 6d0932cd9a2..2686b597554 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -301,6 +301,56 @@ class TestConfig(unittest.TestCase): assert mock_os.path.isdir.call_count == 0 assert mock_shutil.rmtree.call_count == 0 + @mock.patch('homeassistant.config.shutil') + @mock.patch('homeassistant.config.os') + def test_migrate_file_on_upgrade(self, mock_os, mock_shutil): + """Test migrate of config files on upgrade.""" + ha_version = '0.7.0' + + mock_os.path.isdir = mock.Mock(return_value=True) + + mock_open = mock.mock_open() + + def mock_isfile(filename): + return True + + with mock.patch('homeassistant.config.open', mock_open, create=True), \ + mock.patch('homeassistant.config.os.path.isfile', mock_isfile): + opened_file = mock_open.return_value + # pylint: disable=no-member + opened_file.readline.return_value = ha_version + + self.hass.config.path = mock.Mock() + + config_util.process_ha_config_upgrade(self.hass) + + assert mock_os.rename.call_count == 1 + + @mock.patch('homeassistant.config.shutil') + @mock.patch('homeassistant.config.os') + def test_migrate_no_file_on_upgrade(self, mock_os, mock_shutil): + """Test not migrating config files on upgrade.""" + ha_version = '0.7.0' + + mock_os.path.isdir = mock.Mock(return_value=True) + + mock_open = mock.mock_open() + + def mock_isfile(filename): + return False + + with mock.patch('homeassistant.config.open', mock_open, create=True), \ + mock.patch('homeassistant.config.os.path.isfile', mock_isfile): + opened_file = mock_open.return_value + # pylint: disable=no-member + opened_file.readline.return_value = ha_version + + self.hass.config.path = mock.Mock() + + config_util.process_ha_config_upgrade(self.hass) + + assert mock_os.rename.call_count == 0 + def test_loading_configuration(self): """Test loading core config onto hass object.""" self.hass.config = mock.Mock() From cefacf9ce4e9e0146ef57e5ddd5261d4cc3a1766 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Fri, 2 Jun 2017 00:53:23 -0600 Subject: [PATCH 094/105] Spotify aliases (#7702) * Alias support for spotify devices * Fix log * Formatting/Fixes * Remove default arg * Add default keyword * None check --- .../components/media_player/spotify.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 9b9a8c5bb2d..7b1e4bcc1e5 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -40,6 +40,7 @@ AUTH_CALLBACK_NAME = 'api:spotify' ICON = 'mdi:spotify' DEFAULT_NAME = 'Spotify' DOMAIN = 'spotify' +CONF_ALIASES = 'aliases' CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' CONF_CACHE_PATH = 'cache_path' @@ -52,7 +53,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_CACHE_PATH): cv.string + vol.Optional(CONF_CACHE_PATH): cv.string, + vol.Optional(CONF_ALIASES, default={}): {cv.string: cv.string} }) SCAN_INTERVAL = timedelta(seconds=30) @@ -89,7 +91,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): configurator = get_component('configurator') configurator.request_done(hass.data.get(DOMAIN)) del hass.data[DOMAIN] - player = SpotifyMediaPlayer(oauth, config.get(CONF_NAME, DEFAULT_NAME)) + player = SpotifyMediaPlayer(oauth, config.get(CONF_NAME, DEFAULT_NAME), + config[CONF_ALIASES]) add_devices([player], True) @@ -117,7 +120,7 @@ class SpotifyAuthCallbackView(HomeAssistantView): class SpotifyMediaPlayer(MediaPlayerDevice): """Representation of a Spotify controller.""" - def __init__(self, oauth, name): + def __init__(self, oauth, name, aliases): """Initialize.""" self._name = name self._oauth = oauth @@ -128,10 +131,11 @@ class SpotifyMediaPlayer(MediaPlayerDevice): self._image_url = None self._state = STATE_UNKNOWN self._current_device = None - self._devices = None + self._devices = {} self._volume = None self._shuffle = False self._player = None + self._aliases = aliases self._token_info = self._oauth.get_cached_token() def refresh_spotify_instance(self): @@ -154,10 +158,19 @@ class SpotifyMediaPlayer(MediaPlayerDevice): """Update state and attributes.""" self.refresh_spotify_instance() # Available devices - devices = self._player.devices().get('devices') + player_devices = self._player.devices() + if player_devices is not None: + devices = player_devices.get('devices') if devices is not None: - self._devices = {device.get('name'): device.get('id') + old_devices = self._devices + self._devices = {self._aliases.get(device.get('id'), + device.get('name')): + device.get('id') for device in devices} + device_diff = {name: id for name, id in self._devices.items() + if old_devices.get(name, None) is None} + if len(device_diff) > 0: + _LOGGER.info("New Devices: %s", str(device_diff)) # Current playback state current = self._player.current_playback() if current is None: From 613da308f2b2fcb4cd28a242ea93b2eaa6f7a432 Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Fri, 2 Jun 2017 03:01:14 -0400 Subject: [PATCH 095/105] Query in InfluxDB sensor is now templatable (#7634) --- homeassistant/components/sensor/influxdb.py | 39 ++++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sensor/influxdb.py b/homeassistant/components/sensor/influxdb.py index d1d693543be..5aad96965c9 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -16,6 +16,7 @@ from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_USERNAME, from homeassistant.const import STATE_UNKNOWN from homeassistant.util import Throttle +from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -41,7 +42,7 @@ REQUIREMENTS = ['influxdb==3.0.0'] _QUERY_SCHEME = vol.Schema({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_MEASUREMENT_NAME): cv.string, - vol.Required(CONF_WHERE): cv.string, + vol.Required(CONF_WHERE): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_DB_NAME, default=DEFAULT_DATABASE): cv.string, @@ -100,11 +101,10 @@ class InfluxSensor(Entity): database = query.get(CONF_DB_NAME) self._state = None self._hass = hass - formated_query = "select {}({}) as value from {} where {}"\ - .format(query.get(CONF_GROUP_FUNCTION), - query.get(CONF_FIELD), - query.get(CONF_MEASUREMENT_NAME), - query.get(CONF_WHERE)) + + where_clause = query.get(CONF_WHERE) + where_clause.hass = hass + influx = InfluxDBClient( host=influx_conf['host'], port=influx_conf['port'], username=influx_conf['username'], password=influx_conf['password'], @@ -113,7 +113,11 @@ class InfluxSensor(Entity): try: influx.query("select * from /.*/ LIMIT 1;") self.connected = True - self.data = InfluxSensorData(influx, formated_query) + self.data = InfluxSensorData(influx, + query.get(CONF_GROUP_FUNCTION), + query.get(CONF_FIELD), + query.get(CONF_MEASUREMENT_NAME), + where_clause) self.update() except exceptions.InfluxDBClientError as exc: _LOGGER.error("Database host is not accessible due to '%s', please" @@ -157,15 +161,32 @@ class InfluxSensor(Entity): class InfluxSensorData(object): """Class for handling the data retrieval.""" - def __init__(self, influx, query): + def __init__(self, influx, group, field, measurement, where): """Initialize the data object.""" self.influx = influx - self.query = query + self.group = group + self.field = field + self.measurement = measurement + self.where = where self.value = None + self.query = None @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data with a shell command.""" + _LOGGER.info("Rendering where: %s", self.where) + try: + where_clause = self.where.render() + except TemplateError as ex: + _LOGGER.error('Could not render where clause template: %s', ex) + return + + self.query = "select {}({}) as value from {} where {}"\ + .format(self.group, + self.field, + self.measurement, + where_clause) + _LOGGER.info("Running query: %s", self.query) points = list(self.influx.query(self.query).get_points()) From 1855f1ae85e23df758e7c8fa3f4bf20e320ba983 Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Fri, 2 Jun 2017 09:02:26 +0200 Subject: [PATCH 096/105] fix for https://github.com/home-assistant/home-assistant/issues/7019 (#7618) --- homeassistant/components/tellduslive.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index a9f3cea81e7..4c85fe8a2bd 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -7,7 +7,8 @@ https://home-assistant.io/components/tellduslive/ from datetime import datetime, timedelta import logging -from homeassistant.const import ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_START) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -56,7 +57,8 @@ def setup(hass, config): return False hass.data[DOMAIN] = client - client.update(utcnow()) + + hass.bus.listen(EVENT_HOMEASSISTANT_START, client.update) return True @@ -91,7 +93,7 @@ class TelldusLiveClient(object): response = self._client.request_user() return response and 'email' in response - def update(self, now): + def update(self, *args): """Periodically poll the servers for current state.""" _LOGGER.debug("Updating") try: From 12607aeaeac0a3afca3b530667b65c64a44ef5ba Mon Sep 17 00:00:00 2001 From: Juggels Date: Fri, 2 Jun 2017 09:03:10 +0200 Subject: [PATCH 097/105] Check if media commands are actually applicable (#7595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Check if media commands are actually applicable - Explicitly allow ‘stop’ and ‘play’ on radio streams - Disallow media commands when the playlist is empty - Check if command is supported when calling `turn_on` and `turn_off` * Suppress UPnP error 701 on media commands * Clean up soco_filter_upnperror Clean up soco_filter_upnperror and fix small bug in support_previous_track determination --- .../components/media_player/sonos.py | 53 +++++++++++++++++-- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index c209fde1679..2e3f9b5d38e 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) # speaker every 10 seconds. Quiet it down a bit to just actual problems. _SOCO_LOGGER = logging.getLogger('soco') _SOCO_LOGGER.setLevel(logging.ERROR) +_SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') _REQUESTS_LOGGER = logging.getLogger('requests') _REQUESTS_LOGGER.setLevel(logging.ERROR) @@ -73,6 +74,8 @@ ATTR_WITH_GROUP = 'with_group' ATTR_IS_COORDINATOR = 'is_coordinator' +UPNP_ERRORS_TO_IGNORE = ['701'] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ADVERTISE_ADDR): cv.string, vol.Optional(CONF_INTERFACE_ADDR): cv.string, @@ -255,10 +258,36 @@ def soco_error(funct): return funct(*args, **kwargs) except SoCoException as err: _LOGGER.error("Error on %s with %s", funct.__name__, err) - return wrapper +def soco_filter_upnperror(errorcodes=None): + """Filter out specified UPnP errors from logs.""" + def decorator(funct): + """Decorator function.""" + @ft.wraps(funct) + def wrapper(*args, **kwargs): + """Wrap for all soco UPnP exception.""" + from soco.exceptions import SoCoUPnPException + + # Temporarily disable SoCo logging because it will log the + # UPnP exception otherwise + _SOCO_SERVICES_LOGGER.disabled = True + + try: + return funct(*args, **kwargs) + except SoCoUPnPException as err: + if err.error_code in errorcodes: + pass + else: + raise + finally: + _SOCO_SERVICES_LOGGER.disabled = False + + return wrapper + return decorator + + def soco_coordinator(funct): """Call function on coordinator.""" @ft.wraps(funct) @@ -297,6 +326,7 @@ class SonosDevice(MediaPlayerDevice): self._media_next_title = None self._support_previous_track = False self._support_next_track = False + self._support_play = False self._support_stop = False self._support_pause = False self._current_track_uri = None @@ -411,6 +441,7 @@ class SonosDevice(MediaPlayerDevice): self._current_track_is_radio_stream = False self._support_previous_track = False self._support_next_track = False + self._support_play = False self._support_stop = False self._support_pause = False self._is_playing_tv = False @@ -494,6 +525,7 @@ class SonosDevice(MediaPlayerDevice): support_previous_track = False support_next_track = False + support_play = False support_stop = False support_pause = False @@ -515,7 +547,8 @@ class SonosDevice(MediaPlayerDevice): ) support_previous_track = False support_next_track = False - support_stop = False + support_play = True + support_stop = True support_pause = False source_name = 'Radio' @@ -578,6 +611,7 @@ class SonosDevice(MediaPlayerDevice): ) support_previous_track = True support_next_track = True + support_play = True support_stop = True support_pause = True @@ -631,7 +665,7 @@ class SonosDevice(MediaPlayerDevice): if playlist_position is not None and playlist_size is not None: - if playlist_position == 1: + if playlist_position <= 1: support_previous_track = False if playlist_position == playlist_size: @@ -651,6 +685,7 @@ class SonosDevice(MediaPlayerDevice): self._current_track_is_radio_stream = is_radio_stream self._support_previous_track = support_previous_track self._support_next_track = support_next_track + self._support_play = support_play self._support_stop = support_stop self._support_pause = support_pause self._is_playing_tv = is_playing_tv @@ -813,6 +848,9 @@ class SonosDevice(MediaPlayerDevice): if not self._support_next_track: supported = supported ^ SUPPORT_NEXT_TRACK + if not self._support_play: + supported = supported ^ SUPPORT_PLAY + if not self._support_stop: supported = supported ^ SUPPORT_STOP @@ -889,21 +927,25 @@ class SonosDevice(MediaPlayerDevice): @soco_error def turn_off(self): """Turn off media player.""" - self.media_pause() + if self._support_pause: + self.media_pause() @soco_error + @soco_filter_upnperror(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_play(self): """Send play command.""" self._player.play() @soco_error + @soco_filter_upnperror(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_stop(self): """Send stop command.""" self._player.stop() @soco_error + @soco_filter_upnperror(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_pause(self): """Send pause command.""" @@ -936,7 +978,8 @@ class SonosDevice(MediaPlayerDevice): @soco_error def turn_on(self): """Turn the media player on.""" - self.media_play() + if self.support_play: + self.media_play() @soco_error @soco_coordinator From 2b70b1881a05939428f1d6fea93380ba7dd01cca Mon Sep 17 00:00:00 2001 From: David-Leon Pohl Date: Fri, 2 Jun 2017 09:05:07 +0200 Subject: [PATCH 098/105] Quickfix Bug #7384 (#7582) * Quickfix Bug #7384 * Fix devices not available runtime bug --- homeassistant/components/media_player/spotify.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 7b1e4bcc1e5..4992a398b2d 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -225,8 +225,9 @@ class SpotifyMediaPlayer(MediaPlayerDevice): def select_source(self, source): """Select playback device.""" - self._player.transfer_playback(self._devices[source], - self._state == STATE_PLAYING) + if self._devices: + self._player.transfer_playback(self._devices[source], + self._state == STATE_PLAYING) def play_media(self, media_type, media_id, **kwargs): """Play media.""" @@ -271,7 +272,8 @@ class SpotifyMediaPlayer(MediaPlayerDevice): @property def source_list(self): """Return a list of source devices.""" - return list(self._devices.keys()) + if self._devices: + return list(self._devices.keys()) @property def source(self): From d472d81538123ba3959a5a413fe5ffaf4e8016a2 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Fri, 2 Jun 2017 09:05:34 +0200 Subject: [PATCH 099/105] Align switch group handling with light. (#7577) --- homeassistant/components/switch/rflink.py | 30 ++++- tests/components/light/test_rflink.py | 70 +++++------ tests/components/switch/test_rflink.py | 137 +++++++++++++++++++++- 3 files changed, 195 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/switch/rflink.py b/homeassistant/components/switch/rflink.py index 1abeb3eeada..58b1e0959af 100644 --- a/homeassistant/components/switch/rflink.py +++ b/homeassistant/components/switch/rflink.py @@ -9,8 +9,10 @@ import logging from homeassistant.components.rflink import ( CONF_ALIASSES, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, - CONF_SIGNAL_REPETITIONS, DATA_ENTITY_LOOKUP, DEVICE_DEFAULTS_SCHEMA, - DOMAIN, EVENT_KEY_COMMAND, SwitchableRflinkDevice, cv, vol) + CONF_GROUP, CONF_GROUP_ALIASSES, CONF_NOGROUP_ALIASSES, + CONF_SIGNAL_REPETITIONS, DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, + DEVICE_DEFAULTS_SCHEMA, DOMAIN, EVENT_KEY_COMMAND, SwitchableRflinkDevice, + cv, vol) from homeassistant.components.switch import SwitchDevice from homeassistant.const import CONF_NAME, CONF_PLATFORM @@ -27,8 +29,13 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ALIASSES, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_GROUP_ALIASSES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NOGROUP_ALIASSES, default=[]): + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, }, }), }) @@ -43,9 +50,26 @@ def devices_from_config(domain_config, hass=None): devices.append(device) # Register entity (and aliasses) to listen to incoming rflink events - for _id in config[CONF_ALIASSES] + [device_id]: + # Device id and normal aliasses respond to normal and group command + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + if config[CONF_GROUP]: + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + for _id in config[CONF_ALIASSES]: hass.data[DATA_ENTITY_LOOKUP][ EVENT_KEY_COMMAND][_id].append(device) + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + # group_aliasses only respond to group commands + for _id in config[CONF_GROUP_ALIASSES]: + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + # nogroup_aliasses only respond to normal commands + for _id in config[CONF_NOGROUP_ALIASSES]: + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + return devices diff --git a/tests/components/light/test_rflink.py b/tests/components/light/test_rflink.py index 7eaa8e8fc6d..0d34bb6a90f 100644 --- a/tests/components/light/test_rflink.py +++ b/tests/components/light/test_rflink.py @@ -53,7 +53,7 @@ def test_default_setup(hass, monkeypatch): assert create.call_args_list[0][1]['ignore'] # test default state of light loaded from config - light_initial = hass.states.get('light.test') + light_initial = hass.states.get(DOMAIN + '.test') assert light_initial.state == 'off' assert light_initial.attributes['assumed_state'] @@ -67,7 +67,7 @@ def test_default_setup(hass, monkeypatch): }) yield from hass.async_block_till_done() - light_after_first_command = hass.states.get('light.test') + light_after_first_command = hass.states.get(DOMAIN + '.test') assert light_after_first_command.state == 'on' # also after receiving first command state not longer has to be assumed assert 'assumed_state' not in light_after_first_command.attributes @@ -79,7 +79,7 @@ def test_default_setup(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert hass.states.get('light.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' # should repond to group command event_callback({ @@ -88,7 +88,7 @@ def test_default_setup(hass, monkeypatch): }) yield from hass.async_block_till_done() - light_after_first_command = hass.states.get('light.test') + light_after_first_command = hass.states.get(DOMAIN + '.test') assert light_after_first_command.state == 'on' # should repond to group command @@ -98,7 +98,7 @@ def test_default_setup(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert hass.states.get('light.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' # test following aliasses # mock incoming command event for this device alias @@ -108,7 +108,7 @@ def test_default_setup(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert hass.states.get('light.test').state == 'on' + assert hass.states.get(DOMAIN + '.test').state == 'on' # test event for new unconfigured sensor event_callback({ @@ -117,22 +117,22 @@ def test_default_setup(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert hass.states.get('light.protocol2_0_1').state == 'on' + assert hass.states.get(DOMAIN + '.protocol2_0_1').state == 'on' # test changing state from HA propagates to Rflink hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: 'light.test'})) + {ATTR_ENTITY_ID: DOMAIN + '.test'})) yield from hass.async_block_till_done() - assert hass.states.get('light.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' assert protocol.send_command_ack.call_args_list[0][0][0] == 'protocol_0_0' assert protocol.send_command_ack.call_args_list[0][0][1] == 'off' hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: 'light.test'})) + {ATTR_ENTITY_ID: DOMAIN + '.test'})) yield from hass.async_block_till_done() - assert hass.states.get('light.test').state == 'on' + assert hass.states.get(DOMAIN + '.test').state == 'on' assert protocol.send_command_ack.call_args_list[1][0][1] == 'on' # protocols supporting dimming and on/off should create hybrid light entity @@ -143,7 +143,7 @@ def test_default_setup(hass, monkeypatch): yield from hass.async_block_till_done() hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: 'light.newkaku_0_1'})) + {ATTR_ENTITY_ID: DOMAIN + '.newkaku_0_1'})) yield from hass.async_block_till_done() # dimmable should send highest dim level when turning on @@ -155,7 +155,7 @@ def test_default_setup(hass, monkeypatch): hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: 'light.newkaku_0_1', + ATTR_ENTITY_ID: DOMAIN + '.newkaku_0_1', ATTR_BRIGHTNESS: 128, })) yield from hass.async_block_till_done() @@ -165,7 +165,7 @@ def test_default_setup(hass, monkeypatch): hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: 'light.dim_test', + ATTR_ENTITY_ID: DOMAIN + '.dim_test', ATTR_BRIGHTNESS: 128, })) yield from hass.async_block_till_done() @@ -210,7 +210,7 @@ def test_firing_bus_event(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert calls[0].data == {'state': 'off', 'entity_id': 'light.test'} + assert calls[0].data == {'state': 'off', 'entity_id': DOMAIN + '.test'} @asyncio.coroutine @@ -247,7 +247,7 @@ def test_signal_repetitions(hass, monkeypatch): # test if signal repetition is performed according to configuration hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: 'light.test'})) + {ATTR_ENTITY_ID: DOMAIN + '.test'})) # wait for commands and repetitions to finish yield from hass.async_block_till_done() @@ -257,7 +257,7 @@ def test_signal_repetitions(hass, monkeypatch): # test if default apply to configured devcies hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: 'light.test1'})) + {ATTR_ENTITY_ID: DOMAIN + '.test1'})) # wait for commands and repetitions to finish yield from hass.async_block_till_done() @@ -275,7 +275,7 @@ def test_signal_repetitions(hass, monkeypatch): hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: 'light.protocol_0_2'})) + {ATTR_ENTITY_ID: DOMAIN + '.protocol_0_2'})) # wait for commands and repetitions to finish yield from hass.async_block_till_done() @@ -311,10 +311,10 @@ def test_signal_repetitions_alternation(hass, monkeypatch): hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: 'light.test'})) + {ATTR_ENTITY_ID: DOMAIN + '.test'})) hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: 'light.test1'})) + {ATTR_ENTITY_ID: DOMAIN + '.test1'})) yield from hass.async_block_till_done() @@ -348,11 +348,11 @@ def test_signal_repetitions_cancelling(hass, monkeypatch): hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: 'light.test'})) + {ATTR_ENTITY_ID: DOMAIN + '.test'})) hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: 'light.test'})) + {ATTR_ENTITY_ID: DOMAIN + '.test'})) yield from hass.async_block_till_done() @@ -385,7 +385,7 @@ def test_type_toggle(hass, monkeypatch): event_callback, _, _, _ = yield from mock_rflink( hass, config, DOMAIN, monkeypatch) - assert hass.states.get('light.toggle_test').state == 'off' + assert hass.states.get(DOMAIN + '.toggle_test').state == 'off' # test sending on command to toggle alias event_callback({ @@ -394,7 +394,7 @@ def test_type_toggle(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert hass.states.get('light.toggle_test').state == 'on' + assert hass.states.get(DOMAIN + '.toggle_test').state == 'on' # test sending group command to group alias event_callback({ @@ -403,7 +403,7 @@ def test_type_toggle(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert hass.states.get('light.toggle_test').state == 'off' + assert hass.states.get(DOMAIN + '.toggle_test').state == 'off' @asyncio.coroutine @@ -428,7 +428,7 @@ def test_group_alias(hass, monkeypatch): event_callback, _, _, _ = yield from mock_rflink( hass, config, DOMAIN, monkeypatch) - assert hass.states.get('light.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' # test sending group command to group alias event_callback({ @@ -437,7 +437,7 @@ def test_group_alias(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert hass.states.get('light.test').state == 'on' + assert hass.states.get(DOMAIN + '.test').state == 'on' # test sending group command to group alias event_callback({ @@ -446,7 +446,7 @@ def test_group_alias(hass, monkeypatch): }) yield from hass.async_block_till_done() - assert hass.states.get('light.test').state == 'on' + assert hass.states.get(DOMAIN + '.test').state == 'on' @asyncio.coroutine @@ -471,7 +471,7 @@ def test_nogroup_alias(hass, monkeypatch): event_callback, _, _, _ = yield from mock_rflink( hass, config, DOMAIN, monkeypatch) - assert hass.states.get('light.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' # test sending group command to nogroup alias event_callback({ @@ -480,7 +480,7 @@ def test_nogroup_alias(hass, monkeypatch): }) yield from hass.async_block_till_done() # should not affect state - assert hass.states.get('light.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' # test sending group command to nogroup alias event_callback({ @@ -489,7 +489,7 @@ def test_nogroup_alias(hass, monkeypatch): }) yield from hass.async_block_till_done() # should affect state - assert hass.states.get('light.test').state == 'on' + assert hass.states.get(DOMAIN + '.test').state == 'on' @asyncio.coroutine @@ -514,7 +514,7 @@ def test_nogroup_device_id(hass, monkeypatch): event_callback, _, _, _ = yield from mock_rflink( hass, config, DOMAIN, monkeypatch) - assert hass.states.get('light.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' # test sending group command to nogroup event_callback({ @@ -523,7 +523,7 @@ def test_nogroup_device_id(hass, monkeypatch): }) yield from hass.async_block_till_done() # should not affect state - assert hass.states.get('light.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' # test sending group command to nogroup event_callback({ @@ -532,7 +532,7 @@ def test_nogroup_device_id(hass, monkeypatch): }) yield from hass.async_block_till_done() # should affect state - assert hass.states.get('light.test').state == 'on' + assert hass.states.get(DOMAIN + '.test').state == 'on' @asyncio.coroutine @@ -560,4 +560,4 @@ def test_disable_automatic_add(hass, monkeypatch): yield from hass.async_block_till_done() # make sure new device is not added - assert not hass.states.get('light.protocol_0_0') + assert not hass.states.get(DOMAIN + '.protocol_0_0') diff --git a/tests/components/switch/test_rflink.py b/tests/components/switch/test_rflink.py index f5a16e14d07..3952b6f32bc 100644 --- a/tests/components/switch/test_rflink.py +++ b/tests/components/switch/test_rflink.py @@ -86,15 +86,144 @@ def test_default_setup(hass, monkeypatch): # test changing state from HA propagates to Rflink hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: 'switch.test'})) + {ATTR_ENTITY_ID: DOMAIN + '.test'})) yield from hass.async_block_till_done() - assert hass.states.get('switch.test').state == 'off' + assert hass.states.get(DOMAIN + '.test').state == 'off' assert protocol.send_command_ack.call_args_list[0][0][0] == 'protocol_0_0' assert protocol.send_command_ack.call_args_list[0][0][1] == 'off' hass.async_add_job( hass.services.async_call(DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: 'switch.test'})) + {ATTR_ENTITY_ID: DOMAIN + '.test'})) yield from hass.async_block_till_done() - assert hass.states.get('switch.test').state == 'on' + assert hass.states.get(DOMAIN + '.test').state == 'on' assert protocol.send_command_ack.call_args_list[1][0][1] == 'on' + + +@asyncio.coroutine +def test_group_alias(hass, monkeypatch): + """Group aliases should only respond to group commands (allon/alloff).""" + config = { + 'rflink': { + 'port': '/dev/ttyABC0', + }, + DOMAIN: { + 'platform': 'rflink', + 'devices': { + 'protocol_0_0': { + 'name': 'test', + 'group_aliasses': ['test_group_0_0'], + }, + }, + }, + } + + # setup mocking rflink module + event_callback, _, _, _ = yield from mock_rflink( + hass, config, DOMAIN, monkeypatch) + + assert hass.states.get(DOMAIN + '.test').state == 'off' + + # test sending group command to group alias + event_callback({ + 'id': 'test_group_0_0', + 'command': 'allon', + }) + yield from hass.async_block_till_done() + + assert hass.states.get(DOMAIN + '.test').state == 'on' + + # test sending group command to group alias + event_callback({ + 'id': 'test_group_0_0', + 'command': 'off', + }) + yield from hass.async_block_till_done() + + assert hass.states.get(DOMAIN + '.test').state == 'on' + + +@asyncio.coroutine +def test_nogroup_alias(hass, monkeypatch): + """Non group aliases should not respond to group commands.""" + config = { + 'rflink': { + 'port': '/dev/ttyABC0', + }, + DOMAIN: { + 'platform': 'rflink', + 'devices': { + 'protocol_0_0': { + 'name': 'test', + 'nogroup_aliasses': ['test_nogroup_0_0'], + }, + }, + }, + } + + # setup mocking rflink module + event_callback, _, _, _ = yield from mock_rflink( + hass, config, DOMAIN, monkeypatch) + + assert hass.states.get(DOMAIN + '.test').state == 'off' + + # test sending group command to nogroup alias + event_callback({ + 'id': 'test_nogroup_0_0', + 'command': 'allon', + }) + yield from hass.async_block_till_done() + # should not affect state + assert hass.states.get(DOMAIN + '.test').state == 'off' + + # test sending group command to nogroup alias + event_callback({ + 'id': 'test_nogroup_0_0', + 'command': 'on', + }) + yield from hass.async_block_till_done() + # should affect state + assert hass.states.get(DOMAIN + '.test').state == 'on' + + +@asyncio.coroutine +def test_nogroup_device_id(hass, monkeypatch): + """Device id that do not respond to group commands (allon/alloff).""" + config = { + 'rflink': { + 'port': '/dev/ttyABC0', + }, + DOMAIN: { + 'platform': 'rflink', + 'devices': { + 'test_nogroup_0_0': { + 'name': 'test', + 'group': False, + }, + }, + }, + } + + # setup mocking rflink module + event_callback, _, _, _ = yield from mock_rflink( + hass, config, DOMAIN, monkeypatch) + + assert hass.states.get(DOMAIN + '.test').state == 'off' + + # test sending group command to nogroup + event_callback({ + 'id': 'test_nogroup_0_0', + 'command': 'allon', + }) + yield from hass.async_block_till_done() + # should not affect state + assert hass.states.get(DOMAIN + '.test').state == 'off' + + # test sending group command to nogroup + event_callback({ + 'id': 'test_nogroup_0_0', + 'command': 'on', + }) + yield from hass.async_block_till_done() + # should affect state + assert hass.states.get(DOMAIN + '.test').state == 'on' From 4163bcebbc720e5732ec265b50645a4789657621 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 2 Jun 2017 00:13:17 -0700 Subject: [PATCH 100/105] Update netdisco (#7865) --- homeassistant/components/discovery.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index a068deb076c..6132bd565dd 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.0'] +REQUIREMENTS = ['netdisco==1.0.1'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index 54d8e04765d..29f8830d01b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -382,7 +382,7 @@ mutagen==1.37.0 myusps==1.1.1 # homeassistant.components.discovery -netdisco==1.0.0 +netdisco==1.0.1 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From f056cbc641e1c9b68b1439ce974cfd4fb9f0bf20 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 2 Jun 2017 00:20:53 -0700 Subject: [PATCH 101/105] Update frontend --- homeassistant/components/frontend/version.py | 4 +- .../frontend/www_static/frontend.html | 4 +- .../frontend/www_static/frontend.html.gz | Bin 140775 -> 140764 bytes .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-zwave.html | 337 +++++++++++------- .../www_static/panels/ha-panel-zwave.html.gz | Bin 12987 -> 13269 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2512 -> 2514 bytes 8 files changed, 215 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index f4af26cc376..fd7f4c921cb 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": "fbb9d6bdd3d661db26cad9475a5e22f1", + "frontend.html": "ed18c05632c071eb4f7b012382d0f810", "mdi.html": "f407a5a57addbe93817ee1b244d33fbe", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", "panels/ha-panel-automation.html": "21cba0a4fee9d2b45dda47f7a1dd82d8", @@ -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": "19336d2c50c91dd6a122acc0606ff10d", + "panels/ha-panel-zwave.html": "780a792213e98510b475f752c40ef0f9", "websocket_test.html": "575de64b431fe11c3785bf96d7813450" } diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index c0443749f7c..936db8dccde 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -552,7 +552,7 @@ window.hassUtil.computeLocationName = function (hass) { }, }); }()); \ 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 edb01a40d53953905e3727fb0cd4e366ee2e5893..db87c3e66e3f229d1537ea62bcab8179bd1c5c18 100644 GIT binary patch delta 30914 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

;yJk>HP;VV0hPb1u_6 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