From 8523aaca64704b8a1a34368e24dd51e4d71c3354 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 10 Aug 2017 23:26:19 +0200 Subject: [PATCH 001/277] Prepare for release --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 85846e32f59..93bdb947afc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 51 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From 956543ae1eb89bc546930a0f6c8089f37de7d7e8 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 12 Aug 2017 04:55:57 +0200 Subject: [PATCH 002/277] Remove not needed call to update (#8930) * This will ensure no I/O in entity properties. --- homeassistant/components/switch/rachio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py index 547442a4233..c9b6011bcbd 100644 --- a/homeassistant/components/switch/rachio.py +++ b/homeassistant/components/switch/rachio.py @@ -199,7 +199,6 @@ class RachioZone(SwitchDevice): @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') From daf7d9ea7fa892cee5e74339ca92036bece0bc90 Mon Sep 17 00:00:00 2001 From: cribbstechnologies Date: Sat, 12 Aug 2017 11:50:02 -0400 Subject: [PATCH 003/277] fixing emulated hue issue and testing it (#8928) * fixing emulated hue issue and testing it * fixing hound issues * I should probably stop using vim * Check against dict directly instead of items. --- .../components/emulated_hue/__init__.py | 4 +- tests/components/emulated_hue/test_init.py | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 315fc564999..ae0a26aaea4 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -193,7 +193,9 @@ class Config(object): if entity_id == ent_id: return number - number = str(max(int(k) for k in self.numbers) + 1) + number = '1' + if self.numbers: + number = str(max(int(k) for k in self.numbers) + 1) self.numbers[number] = entity_id self._save_numbers_json() return number diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 8c0a6dc4f60..2dcb9ecbf21 100755 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -36,6 +36,65 @@ def test_config_google_home_entity_id_to_number(): assert entity_id == 'light.test2' +def test_config_google_home_entity_id_to_number_altered(): + """Test config adheres to the type.""" + conf = Config(Mock(), { + 'type': 'google_home' + }) + + mop = mock_open(read_data=json.dumps({'21': 'light.test2'})) + handle = mop() + + with patch('homeassistant.components.emulated_hue.open', mop, create=True): + number = conf.entity_id_to_number('light.test') + assert number == '22' + assert handle.write.call_count == 1 + assert json.loads(handle.write.mock_calls[0][1][0]) == { + '21': 'light.test2', + '22': 'light.test', + } + + number = conf.entity_id_to_number('light.test') + assert number == '22' + assert handle.write.call_count == 1 + + number = conf.entity_id_to_number('light.test2') + assert number == '21' + assert handle.write.call_count == 1 + + entity_id = conf.number_to_entity_id('21') + assert entity_id == 'light.test2' + + +def test_config_google_home_entity_id_to_number_empty(): + """Test config adheres to the type.""" + conf = Config(Mock(), { + 'type': 'google_home' + }) + + mop = mock_open(read_data='') + handle = mop() + + with patch('homeassistant.components.emulated_hue.open', mop, create=True): + number = conf.entity_id_to_number('light.test') + assert number == '1' + assert handle.write.call_count == 1 + assert json.loads(handle.write.mock_calls[0][1][0]) == { + '1': 'light.test', + } + + number = conf.entity_id_to_number('light.test') + assert number == '1' + assert handle.write.call_count == 1 + + number = conf.entity_id_to_number('light.test2') + assert number == '2' + assert handle.write.call_count == 2 + + entity_id = conf.number_to_entity_id('2') + assert entity_id == 'light.test2' + + def test_config_alexa_entity_id_to_number(): """Test config adheres to the type.""" conf = Config(None, { From 5814fdadd0e4df5e86281534188de5b69be2a4cb Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Fri, 11 Aug 2017 11:22:22 +0200 Subject: [PATCH 004/277] Update roombapy to 1.3.1 to avoid installing all the mapping dependencies (#8925) --- homeassistant/components/vacuum/roomba.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index b6dcda4bcaa..cf9ee064283 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -17,7 +17,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['roombapy==1.3.0'] +REQUIREMENTS = ['roombapy==1.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 91be5eb7be3..ad38eb8aa05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -838,7 +838,7 @@ rflink==0.0.34 ring_doorbell==0.1.4 # homeassistant.components.vacuum.roomba -roombapy==1.3.0 +roombapy==1.3.1 # homeassistant.components.switch.rpi_rf # rpi-rf==0.9.6 From 08899ade0029364ba10e161638237927522bbfad Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Fri, 11 Aug 2017 02:35:45 -0400 Subject: [PATCH 005/277] Update python-wink version to fix Dome water valve bug. (#8923) --- homeassistant/components/switch/wink.py | 3 ++- homeassistant/components/wink.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index ec9311ac9e9..aa33c2f7132 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -65,7 +65,8 @@ class WinkToggleDevice(WinkDevice, ToggleEntity): attributes = super(WinkToggleDevice, self).device_state_attributes try: event = self.wink.last_event() - attributes["last_event"] = event + if event is not None: + attributes["last_event"] = event except AttributeError: pass return attributes diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 316c939492b..8d40f5dad48 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-wink==1.4.2', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.5.1', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ad38eb8aa05..07bb6846c61 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -775,7 +775,7 @@ python-velbus==2.0.11 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.4.2 +python-wink==1.5.1 # homeassistant.components.zwave python_openzwave==0.4.0.31 From eef3dda1e92b68119107b41f3ecbb2af7b6fc5d2 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 12 Aug 2017 18:39:05 +0200 Subject: [PATCH 006/277] Fix SET_TEMPERATURE_SCHEMA in climate component (#8879) * Require either temperature or high/low target temperatures. * Add tests. --- homeassistant/components/climate/__init__.py | 18 +++++---- tests/common.py | 5 ++- tests/components/climate/test_init.py | 40 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 tests/components/climate/test_init.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 6dd66817d43..1f919301254 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -86,13 +86,17 @@ SET_AUX_HEAT_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_AUX_HEAT): cv.boolean, }) -SET_TEMPERATURE_SCHEMA = vol.Schema({ - vol.Exclusive(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float), - vol.Inclusive(ATTR_TARGET_TEMP_HIGH, 'temperature'): vol.Coerce(float), - vol.Inclusive(ATTR_TARGET_TEMP_LOW, 'temperature'): vol.Coerce(float), - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_OPERATION_MODE): cv.string, -}) +SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All( + cv.has_at_least_one_key( + ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW), + { + vol.Exclusive(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_HIGH, 'temperature'): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_LOW, 'temperature'): vol.Coerce(float), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_OPERATION_MODE): cv.string, + } +)) SET_FAN_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_FAN_MODE): cv.string, diff --git a/tests/common.py b/tests/common.py index 5e328959a7a..5fdec2fc411 100644 --- a/tests/common.py +++ b/tests/common.py @@ -174,7 +174,7 @@ def get_test_instance_port(): @ha.callback -def async_mock_service(hass, domain, service): +def async_mock_service(hass, domain, service, schema=None): """Set up a fake service & return a calls log list to this service.""" calls = [] @@ -183,7 +183,8 @@ def async_mock_service(hass, domain, service): """Mock service call.""" calls.append(call) - hass.services.async_register(domain, service, mock_service_log) + hass.services.async_register( + domain, service, mock_service_log, schema=schema) return calls diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py new file mode 100644 index 00000000000..2e942c5988c --- /dev/null +++ b/tests/components/climate/test_init.py @@ -0,0 +1,40 @@ +"""The tests for the climate component.""" +import asyncio + +from homeassistant.components.climate import SET_TEMPERATURE_SCHEMA +from tests.common import async_mock_service + + +@asyncio.coroutine +def test_set_temp_schema_no_req(hass, caplog): + """Test the set temperature schema with missing required data.""" + domain = 'climate' + service = 'test_set_temperature' + schema = SET_TEMPERATURE_SCHEMA + calls = async_mock_service(hass, domain, service, schema) + + data = {'operation_mode': 'test', 'entity_id': ['climate.test_id']} + yield from hass.services.async_call(domain, service, data) + yield from hass.async_block_till_done() + + assert len(calls) == 0 + assert 'ERROR' in caplog.text + assert 'Invalid service data' in caplog.text + + +@asyncio.coroutine +def test_set_temp_schema(hass, caplog): + """Test the set temperature schema with ok required data.""" + domain = 'climate' + service = 'test_set_temperature' + schema = SET_TEMPERATURE_SCHEMA + calls = async_mock_service(hass, domain, service, schema) + + data = { + 'temperature': 20.0, 'operation_mode': 'test', + 'entity_id': ['climate.test_id']} + yield from hass.services.async_call(domain, service, data) + yield from hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[-1].data == data From 46d9d77d03b91b3c57e4b712c088e3975d8ea1cc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Aug 2017 14:54:50 -0700 Subject: [PATCH 007/277] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-config.html | 2 +- .../www_static/panels/ha-panel-config.html.gz | Bin 20661 -> 20665 bytes 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 001e369602b..59b09aa4ca1 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -6,7 +6,7 @@ FINGERPRINTS = { "frontend.html": "fb225cfababf965f8e19a8eb5c5a2a7e", "mdi.html": "e91f61a039ed0a9936e7ee5360da3870", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "878fd176dad70fe5cb8fc3c4ca72145c", + "panels/ha-panel-config.html": "ec48185c79000d0cfe5bbf38c7974944", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index a51b9c1eb5a..823a6996708 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit a51b9c1eb5aecd119706a64c353b5df1de0af8cd +Subproject commit 823a6996708d5b85097fb1db0defbc03516e77bf diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html index 98742c6ac29..d645ff87f8e 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 775e05b4a0207047491e526b6172a078c99639a2..e794d3735977c39139d3b34360a40ad42c371c37 100644 GIT binary patch delta 7808 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 SCB=BO`~Ly;5tJT)e*plD&QI_F delta 7804 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 Date: Sat, 12 Aug 2017 14:56:34 -0700 Subject: [PATCH 008/277] Version bump to 0.51.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 93bdb947afc..68413994a80 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 51 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From 56597d290c4ef71687738193b04ad742149195d8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Aug 2017 21:53:22 -0700 Subject: [PATCH 009/277] Version bump to 0.51.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 68413994a80..ab94bd3e420 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 51 -PATCH_VERSION = '1' +PATCH_VERSION = '2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From fc8e8e5d8c54acdccc88f062dfaea4d2e747d9a5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Aug 2017 21:52:36 -0700 Subject: [PATCH 010/277] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../frontend/www_static/frontend.html | 8 ++++---- .../frontend/www_static/frontend.html.gz | Bin 165967 -> 165958 bytes .../www_static/home-assistant-polymer | 2 +- .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5139 -> 5141 bytes 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 59b09aa4ca1..07cd39ca581 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,7 +3,7 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "fb225cfababf965f8e19a8eb5c5a2a7e", + "frontend.html": "5a2a3d6181cc820f5b3e94d1a50def74", "mdi.html": "e91f61a039ed0a9936e7ee5360da3870", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", "panels/ha-panel-config.html": "ec48185c79000d0cfe5bbf38c7974944", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 1281ef93b0d..71c4381108a 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -50,7 +50,7 @@ .input-content.is-invalid ::slotted(textarea), .input-content.is-invalid ::slotted(iron-autogrow-textarea), .input-content.is-invalid ::slotted(.paper-input-input){@apply --paper-input-container-input-invalid;}.prefix ::slotted(*){display:inline-block;@apply --paper-font-subhead;@apply --layout-flex-none;@apply --paper-input-prefix;}.suffix ::slotted(*){display:inline-block;@apply --paper-font-subhead;@apply --layout-flex-none;@apply --paper-input-suffix;}.input-content ::slotted(input){min-width:0;}.input-content ::slotted(textarea){resize:none;}.add-on-content{position:relative;}.add-on-content.is-invalid ::slotted(*){color:var(--paper-input-container-invalid-color, var(--error-color));}.add-on-content.is-highlighted ::slotted(*){color:var(--paper-input-container-focus-color, var(--primary-color));}
\ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 9c8b72d6bccbb68931d502f47b45899b3c0387f8..335c067e4b5e797fe65c72dd74463b0875e0733b 100644 GIT binary patch literal 32839 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 B+hYI# literal 26600 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 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html index d55e008d907..53638dd582b 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html @@ -1,2 +1,2 @@ \ No newline at end of file + clear: both;white-space:pre-wrap;}.rendered.error{color:red;}
Templates
\ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz index 9d8f4d9f5eb8e9586d9ab482c983d4042657c719..24fd95f17a7b7f52b707b380fb29594fe4469557 100644 GIT binary patch literal 1386 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 L-c=8;J`4Z=<(9Ov diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html index e2a93ae1cea..5f34f7bc28a 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index 801450f1bd8d95079910db89eeaac7099433f571..d9dd4c687fbc9ff1e4d195013ef305e915634976 100644 GIT binary patch literal 40802 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%0A>sI4vi8!5jx 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 diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index a5544a8b165..b6a287fde84 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -37,7 +37,7 @@ /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 'use strict'; -var precacheConfig = [["/","535d629ec4d3936dba0ca4ca84dabeb2"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-f47b6910d8e4880e22cc508ca452f9b6.html","9aa0675e01373c6bc2737438bb84a9ec"],["/frontend/panels/map-c2544fff3eedb487d44105cf94b335ec.html","113c5bf9a68a74c62e50cd354034e78b"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-6c8192a4393c9e83516dc8177b75c23d.html","56d5bfe9e11a8b81a686f20aeae3c359"],["/static/mdi-e91f61a039ed0a9936e7ee5360da3870.html","5e587bc82719b740a4f0798722a83aee"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; +var precacheConfig = [["/","f16ed1a09418a161f933bda67dc83fbe"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-3ce24a1e0bc1c6620373f38a2d11b359.html","4dcc9dcddbe093ebd5bc3ecc6dbaebb4"],["/static/mdi-e91f61a039ed0a9936e7ee5360da3870.html","5e587bc82719b740a4f0798722a83aee"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 97665c7f31e22c8c6a05b60599ba2bd3ff53391e..7062ec35d679bda11a4cd0fb5132d862a3912b4c 100644 GIT binary patch literal 5139 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
d`&$yq~Z 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 Date: Mon, 28 Aug 2017 18:09:36 +0200 Subject: [PATCH 049/277] Prevent iCloud exceptions in logfile (#9179) * Prevent iCloud exceptions in logfile With this change ValueError exceptions in the logfile caused by this component will disappear. These errors are caused by the iCloud API returning an HTTP 450 error and the external lib throwing a ValueError because of it. A PR has been raised against the external library, but that fix did not yet make it into a new version of the library. This will catch the exception in the mean time.... https://github.com/picklepete/pyicloud/pull/138 * Align log messages --- .../components/device_tracker/icloud.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index f20dad1fceb..e670287dd87 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -307,12 +307,15 @@ class Icloud(DeviceScanner): self.api.authenticate() currentminutes = dt_util.now().hour * 60 + dt_util.now().minute - for devicename in self.devices: - interval = self._intervals.get(devicename, 1) - if ((currentminutes % interval == 0) or - (interval > 10 and - currentminutes % interval in [2, 4])): - self.update_device(devicename) + try: + for devicename in self.devices: + interval = self._intervals.get(devicename, 1) + if ((currentminutes % interval == 0) or + (interval > 10 and + currentminutes % interval in [2, 4])): + self.update_device(devicename) + except ValueError: + _LOGGER.debug("iCloud API returned an error") def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" @@ -397,7 +400,7 @@ class Icloud(DeviceScanner): self.see(**kwargs) self.seen_devices[devicename] = True except PyiCloudNoDevicesException: - _LOGGER.error('No iCloud Devices found!') + _LOGGER.error("No iCloud Devices found") def lost_iphone(self, devicename): """Call the lost iPhone function if the device is found.""" From 10e3c00f0797471b1d1e87e0b755318069785323 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 28 Aug 2017 09:11:11 -0700 Subject: [PATCH 050/277] Version bump to 0.52.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a8fefcf26c4..15079b11992 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 52 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From 8a896433387827a7b3237309969ee5c43a5dbadb Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Sat, 26 Aug 2017 23:56:39 +0700 Subject: [PATCH 051/277] Close stream request once we end up with proxy (#9110) * Close stream request once we end up with proxy * Update aiohttp_client.py * Update aiohttp_client.py * Removed trailing whitespace --- homeassistant/helpers/aiohttp_client.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index a8b18351021..29e2a6260fd 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -90,8 +90,15 @@ def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, # Something went wrong with the connection raise HTTPBadGateway() from err - yield from async_aiohttp_proxy_stream(hass, request, req.content, - req.headers.get(CONTENT_TYPE)) + try: + yield from async_aiohttp_proxy_stream( + hass, + request, + req.content, + req.headers.get(CONTENT_TYPE) + ) + finally: + req.close() @asyncio.coroutine From 0ae1f85f9fb5df4e60fb7310f1dc156e6b48c38f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 26 Aug 2017 09:36:54 +0200 Subject: [PATCH 052/277] Fix issue #9116 in pushbullet (#9128) * Fix issue #9116 in pushbullet --- homeassistant/components/notify/pushbullet.py | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 6d97d544905..353e833ae51 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -89,7 +89,7 @@ class PushBulletNotificationService(BaseNotificationService): if not targets: # Backward compatibility, notify all devices in own account - self._push_data(filepath, message, title, url) + self._push_data(filepath, message, title, self.pushbullet, url) _LOGGER.info("Sent notification to self") return @@ -104,7 +104,8 @@ class PushBulletNotificationService(BaseNotificationService): # Target is email, send directly, don't use a target object # This also seems works to send to all devices in own account if ttype == 'email': - self._push_data(filepath, message, title, url, tname) + self._push_data(filepath, message, title, url, + self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) continue @@ -123,27 +124,27 @@ class PushBulletNotificationService(BaseNotificationService): # Attempt push_note on a dict value. Keys are types & target # name. Dict pbtargets has all *actual* targets. try: - if url: - self.pbtargets[ttype][tname].push_link( - title, url, body=message) - else: - self.pbtargets[ttype][tname].push_note(title, message) + self._push_data(filepath, message, title, url, + self.pbtargets[ttype][tname]) _LOGGER.info("Sent notification to %s/%s", ttype, tname) except KeyError: _LOGGER.error("No such target: %s/%s", ttype, tname) continue - except self.pushbullet.errors.PushError: - _LOGGER.error("Notify failed to: %s/%s", ttype, tname) - continue - def _push_data(self, filepath, message, title, url, tname=None): - if url: - self.pushbullet.push_link( - title, url, body=message, email=tname) - elif filepath and self.hass.config.is_allowed_path(filepath): - with open(filepath, "rb") as fileh: - filedata = self.pushbullet.upload_file(fileh, filepath) - self.pushbullet.push_file(title=title, body=message, - **filedata) - else: - self.pushbullet.push_note(title, message, email=tname) + def _push_data(self, filepath, message, title, url, pusher, tname=None): + from pushbullet import PushError + try: + if url: + pusher.push_link(title, url, body=message, email=tname) + elif filepath and self.hass.config.is_allowed_path(filepath): + with open(filepath, "rb") as fileh: + filedata = self.pushbullet.upload_file(fileh, filepath) + if filedata.get('file_type') == 'application/x-empty': + _LOGGER.error("Failed to send an empty file.") + return + pusher.push_file(title=title, body=message, **filedata) + else: + pusher.push_note(title, message, email=tname) + + except PushError as err: + _LOGGER.error("Notify failed: %s", err) From 422be25d222d1de4d211b3059d4f14aabbcea2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 26 Aug 2017 18:12:51 +0200 Subject: [PATCH 053/277] bug fix pushbullet (#9139) --- homeassistant/components/notify/pushbullet.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 353e833ae51..0d596fb41ba 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -133,9 +133,13 @@ class PushBulletNotificationService(BaseNotificationService): def _push_data(self, filepath, message, title, url, pusher, tname=None): from pushbullet import PushError + from pushbullet import Device try: if url: - pusher.push_link(title, url, body=message, email=tname) + if isinstance(pusher, Device): + pusher.push_link(title, url, body=message) + else: + pusher.push_link(title, url, body=message, email=tname) elif filepath and self.hass.config.is_allowed_path(filepath): with open(filepath, "rb") as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) @@ -144,7 +148,9 @@ class PushBulletNotificationService(BaseNotificationService): return pusher.push_file(title=title, body=message, **filedata) else: - pusher.push_note(title, message, email=tname) - + if isinstance(pusher, Device): + pusher.push_note(title, message) + else: + pusher.push_note(title, message, email=tname) except PushError as err: _LOGGER.error("Notify failed: %s", err) From e6892a4077be135e72eb208a20a7bbe1208fa0db Mon Sep 17 00:00:00 2001 From: Sean Dague Date: Sat, 26 Aug 2017 12:08:37 -0400 Subject: [PATCH 054/277] Fix import for foscam (#9140) While waiting for a new pyfoscam release, we can fix this for users just by changing the import. Foscam devices a pretty widely deployed, so a regression here is definitely no fun. Fixes Bug #8940 --- homeassistant/components/camera/foscam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 15138e2c253..8ea90d5a44e 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -53,7 +53,7 @@ class FoscamCam(Camera): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam import FoscamCamera + from foscam.foscam import FoscamCamera self._foscam_session = FoscamCamera(ip_address, port, self._username, self._password, verbose=False) From d986b8f4c2e589ee0e6fd4d61e03c606714e0f37 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 26 Aug 2017 17:09:57 -0400 Subject: [PATCH 055/277] Bump aioautomatic to prevent leaking exceptions (#9148) --- homeassistant/components/device_tracker/automatic.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 071edf42642..a4495926f82 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.0'] +REQUIREMENTS = ['aioautomatic==0.6.2'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f1fc40a2187..9349c884272 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -42,7 +42,7 @@ TwitterAPI==2.4.6 abodepy==0.7.1 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.0 +aioautomatic==0.6.2 # homeassistant.components.sensor.dnsip aiodns==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74717aa7d7b..f286555833e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -27,7 +27,7 @@ PyJWT==1.5.2 SoCo==0.12 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.0 +aioautomatic==0.6.2 # homeassistant.components.emulated_hue # homeassistant.components.http From 308b822832b423765fdd6ecd2209d4cd7dae4eed Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 26 Aug 2017 17:00:59 -0700 Subject: [PATCH 056/277] Wrap state when iterating a domain in templates (#9157) --- homeassistant/helpers/template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index aa6ca186a8e..bdef5541983 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -209,7 +209,7 @@ class DomainStates(object): def __iter__(self): """Return the iteration over all the states.""" return iter(sorted( - (state for state in self._hass.states.async_all() + (_wrap_state(state) for state in self._hass.states.async_all() if state.domain == self._domain), key=lambda state: state.entity_id)) From 3509ecf07ff7fca7a3409e28462ac39ba12cc948 Mon Sep 17 00:00:00 2001 From: mjj4791 Date: Mon, 28 Aug 2017 18:09:36 +0200 Subject: [PATCH 057/277] Prevent iCloud exceptions in logfile (#9179) * Prevent iCloud exceptions in logfile With this change ValueError exceptions in the logfile caused by this component will disappear. These errors are caused by the iCloud API returning an HTTP 450 error and the external lib throwing a ValueError because of it. A PR has been raised against the external library, but that fix did not yet make it into a new version of the library. This will catch the exception in the mean time.... https://github.com/picklepete/pyicloud/pull/138 * Align log messages --- .../components/device_tracker/icloud.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index f20dad1fceb..e670287dd87 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -307,12 +307,15 @@ class Icloud(DeviceScanner): self.api.authenticate() currentminutes = dt_util.now().hour * 60 + dt_util.now().minute - for devicename in self.devices: - interval = self._intervals.get(devicename, 1) - if ((currentminutes % interval == 0) or - (interval > 10 and - currentminutes % interval in [2, 4])): - self.update_device(devicename) + try: + for devicename in self.devices: + interval = self._intervals.get(devicename, 1) + if ((currentminutes % interval == 0) or + (interval > 10 and + currentminutes % interval in [2, 4])): + self.update_device(devicename) + except ValueError: + _LOGGER.debug("iCloud API returned an error") def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" @@ -397,7 +400,7 @@ class Icloud(DeviceScanner): self.see(**kwargs) self.seen_devices[devicename] = True except PyiCloudNoDevicesException: - _LOGGER.error('No iCloud Devices found!') + _LOGGER.error("No iCloud Devices found") def lost_iphone(self, devicename): """Call the lost iPhone function if the device is found.""" From 0ccff6c03e2be946dd9c090772c485fb327401b1 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Mon, 28 Aug 2017 11:12:21 -0400 Subject: [PATCH 058/277] bump ecobee version to fix issue 9190 (#9191) --- homeassistant/components/ecobee.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 9e74299e6bc..c4b0f2e9546 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -15,7 +15,7 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle -REQUIREMENTS = ['python-ecobee-api==0.0.8'] +REQUIREMENTS = ['python-ecobee-api==0.0.9'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9349c884272..7ec81558945 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -716,7 +716,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.12 # homeassistant.components.ecobee -python-ecobee-api==0.0.8 +python-ecobee-api==0.0.9 # homeassistant.components.climate.eq3btsmart # python-eq3bt==0.1.5 From e76e9e0966b01dd16e8e5205a3825b6e8d30ce39 Mon Sep 17 00:00:00 2001 From: Mario Wenzel Date: Mon, 28 Aug 2017 21:46:31 +0200 Subject: [PATCH 059/277] Fix dht22 when no data was read initially #8976 (#9198) This fixes https://github.com/home-assistant/home-assistant/issues/8976 When no data was available the module crashes. --- homeassistant/components/sensor/dht.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 8fa34d50137..cbf06783dc7 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -127,7 +127,7 @@ class DHTSensor(Entity): humidity_offset = self.humidity_offset data = self.dht_client.data - if self.type == SENSOR_TEMPERATURE: + if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug("Temperature %.1f \u00b0C + offset %.1f", temperature, temperature_offset) @@ -135,7 +135,7 @@ class DHTSensor(Entity): self._state = round(temperature + temperature_offset, 1) if self.temp_unit == TEMP_FAHRENHEIT: self._state = round(celsius_to_fahrenheit(temperature), 1) - elif self.type == SENSOR_HUMIDITY: + elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) From 6505019701d57bf497cd42fc087e4a2e7a9ef546 Mon Sep 17 00:00:00 2001 From: bobnwk Date: Tue, 29 Aug 2017 05:40:33 +0200 Subject: [PATCH 060/277] Update pushbullet.py (#9200) --- homeassistant/components/notify/pushbullet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 0d596fb41ba..e52348c3446 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -89,7 +89,7 @@ class PushBulletNotificationService(BaseNotificationService): if not targets: # Backward compatibility, notify all devices in own account - self._push_data(filepath, message, title, self.pushbullet, url) + self._push_data(filepath, message, title, url, self.pushbullet) _LOGGER.info("Sent notification to self") return From 0de6a3782283233fd9e2cc91c660eea64e800d65 Mon Sep 17 00:00:00 2001 From: aetolus Date: Tue, 29 Aug 2017 16:28:40 +1000 Subject: [PATCH 061/277] fix worldtidesinfo #9184 (#9201) --- homeassistant/components/sensor/worldtidesinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/worldtidesinfo.py b/homeassistant/components/sensor/worldtidesinfo.py index c9a42f3cb11..f23d244cf3a 100644 --- a/homeassistant/components/sensor/worldtidesinfo.py +++ b/homeassistant/components/sensor/worldtidesinfo.py @@ -88,7 +88,7 @@ class WorldTidesInfoSensor(Entity): return "High tide at %s" % (tidetime) elif "Low" in str(self.data['extremes'][0]['type']): tidetime = time.strftime('%I:%M %p', time.localtime( - self.data['extremes'][1]['dt'])) + self.data['extremes'][0]['dt'])) return "Low tide at %s" % (tidetime) else: return STATE_UNKNOWN From 75559cb81fc41be329e669cad730cc288219c27c Mon Sep 17 00:00:00 2001 From: Trevor Date: Tue, 29 Aug 2017 08:33:27 -0500 Subject: [PATCH 062/277] Add "status" to Sonarr sensor (#9204) * Use X-Api-Key header * Increase timeout * Add "status" to Sonarr sensor * Update test_sonarr.py * Update test_sonarr.py * Update test_sonarr.py * Update sonarr.py * Update sonarr.py --- homeassistant/components/sensor/radarr.py | 2 +- homeassistant/components/sensor/sonarr.py | 41 +++++++++++++-------- tests/components/sensor/test_sonarr.py | 44 +++++++++++++++++++++++ 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/radarr.py b/homeassistant/components/sensor/radarr.py index 03fbce3e79a..33a09a51aef 100644 --- a/homeassistant/components/sensor/radarr.py +++ b/homeassistant/components/sensor/radarr.py @@ -162,7 +162,7 @@ class RadarrSensor(Entity): res = requests.get( ENDPOINTS[self.type].format( self.ssl, self.host, self.port, self.urlbase, start, end), - headers={'X-Api-Key': self.apikey}, timeout=5) + headers={'X-Api-Key': self.apikey}, timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False diff --git a/homeassistant/components/sensor/sonarr.py b/homeassistant/components/sensor/sonarr.py index 143fcee0a61..4be5582b8c4 100644 --- a/homeassistant/components/sensor/sonarr.py +++ b/homeassistant/components/sensor/sonarr.py @@ -36,17 +36,19 @@ SENSOR_TYPES = { 'upcoming': ['Upcoming', 'Episodes', 'mdi:television'], 'wanted': ['Wanted', 'Episodes', 'mdi:television'], 'series': ['Series', 'Shows', 'mdi:television'], - 'commands': ['Commands', 'Commands', 'mdi:code-braces'] + 'commands': ['Commands', 'Commands', 'mdi:code-braces'], + 'status': ['Status', 'Status', 'mdi:information'] } ENDPOINTS = { - 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace?apikey={4}', - 'queue': 'http{0}://{1}:{2}/{3}api/queue?apikey={4}', + 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace', + 'queue': 'http{0}://{1}:{2}/{3}api/queue', 'upcoming': - 'http{0}://{1}:{2}/{3}api/calendar?apikey={4}&start={5}&end={6}', - 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing?apikey={4}', - 'series': 'http{0}://{1}:{2}/{3}api/series?apikey={4}', - 'commands': 'http{0}://{1}:{2}/{3}api/command?apikey={4}' + 'http{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}', + 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing', + 'series': 'http{0}://{1}:{2}/{3}api/series', + 'commands': 'http{0}://{1}:{2}/{3}api/command', + 'status': 'http{0}://{1}:{2}/{3}api/system/status' } # Support to Yottabytes for the future, why not @@ -156,6 +158,8 @@ class SonarrSensor(Entity): for show in self.data: attributes[show['title']] = '{}/{} Episodes'.format( show['episodeFileCount'], show['episodeCount']) + elif self.type == 'status': + attributes = self.data return attributes @property @@ -168,9 +172,12 @@ class SonarrSensor(Entity): start = get_date(self._tz) end = get_date(self._tz, self.days) try: - res = requests.get(ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, self.apikey, - start, end), timeout=5) + res = requests.get( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, + self.urlbase, start, end), + headers={'X-Api-Key': self.apikey}, + timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False @@ -193,10 +200,13 @@ class SonarrSensor(Entity): self._state = len(self.data) elif self.type == 'wanted': data = res.json() - res = requests.get('{}&pageSize={}'.format( - ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, - self.apikey), data['totalRecords']), timeout=5) + res = requests.get( + '{}?pageSize={}'.format( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, self.urlbase), + data['totalRecords']), + headers={'X-Api-Key': self.apikey}, + timeout=10) self.data = res.json()['records'] self._state = len(self.data) elif self.type == 'diskspace': @@ -217,6 +227,9 @@ class SonarrSensor(Entity): self._unit ) ) + elif self.type == 'status': + self.data = res.json() + self._state = self.data['version'] self._available = True diff --git a/tests/components/sensor/test_sonarr.py b/tests/components/sensor/test_sonarr.py index b71b96e1400..bd0011597af 100644 --- a/tests/components/sensor/test_sonarr.py +++ b/tests/components/sensor/test_sonarr.py @@ -549,6 +549,25 @@ def mocked_requests_get(*args, **kwargs): "totalSpace": 499738734592 } ], 200) + elif 'api/system/status' in url: + return MockResponse({ + "version": "2.0.0.1121", + "buildTime": "2014-02-08T20:49:36.5560392Z", + "isDebug": "false", + "isProduction": "true", + "isAdmin": "true", + "isUserInteractive": "false", + "startupPath": "C:\\ProgramData\\NzbDrone\\bin", + "appData": "C:\\ProgramData\\NzbDrone", + "osVersion": "6.2.9200.0", + "isMono": "false", + "isLinux": "false", + "isWindows": "true", + "branch": "develop", + "authentication": "false", + "startOfWeek": 0, + "urlBase": "" + }, 200) else: return MockResponse({ "error": "Unauthorized" @@ -794,6 +813,31 @@ class TestSonarrSetup(unittest.TestCase): device.device_state_attributes["Bob's Burgers"] ) + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_system_status(self, req_mock): + """Test getting system status""" + config = { + 'platform': 'sonarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'status' + ] + } + sonarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual('2.0.0.1121', device.state) + self.assertEqual('mdi:information', device.icon) + self.assertEqual('Sonarr Status', device.name) + self.assertEqual( + '6.2.9200.0', + device.device_state_attributes['osVersion']) + @pytest.mark.skip @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) def test_ssl(self, req_mock): From 5d800c1d519584e36c84ede7bd9f1aef3029e129 Mon Sep 17 00:00:00 2001 From: mjj4791 Date: Tue, 29 Aug 2017 15:33:47 +0200 Subject: [PATCH 063/277] Prevent error when no forecast data was available (#9176) * Prevent error when no forecast data was available Prevent an Error when buienradar data was available, but no forecasted data was retrieved for the requested day. * Update buienradar.py * Update buienradar.py --- homeassistant/components/sensor/buienradar.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 8961fa1dc74..1b5cfc4b491 100755 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -220,7 +220,12 @@ class BrSensor(Entity): # update all other sensors if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): - condition = data.get(FORECAST)[fcday].get(CONDITION) + try: + condition = data.get(FORECAST)[fcday].get(CONDITION) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + if condition: new_state = condition.get(CONDITION, None) if self.type.startswith(SYMBOL): @@ -240,7 +245,11 @@ class BrSensor(Entity): return True return False else: - new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + try: + new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False if new_state != self._state: self._state = new_state From 38071501b421627dadbceb4bbdced195ac4ec620 Mon Sep 17 00:00:00 2001 From: Dale Higgs Date: Tue, 29 Aug 2017 08:38:42 -0500 Subject: [PATCH 064/277] Fix and optimize digitalloggers platform (#9203) * Fix and optimize digitalloggers platform * Fix line length * Fix hanging indentation * Add missing docstring * Add period to end of docstring * Add second blank line --- .../components/switch/digitalloggers.py | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/switch/digitalloggers.py b/homeassistant/components/switch/digitalloggers.py index 26493122184..0625a42f765 100755 --- a/homeassistant/components/switch/digitalloggers.py +++ b/homeassistant/components/switch/digitalloggers.py @@ -46,7 +46,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import dlipower host = config.get(CONF_HOST) - controllername = config.get(CONF_NAME) + controller_name = config.get(CONF_NAME) user = config.get(CONF_USERNAME) pswd = config.get(CONF_PASSWORD) tout = config.get(CONF_TIMEOUT) @@ -61,37 +61,42 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Could not connect to DIN III Relay") return False - devices = [] + outlets = [] parent_device = DINRelayDevice(power_switch) - devices.extend( - DINRelay(controllername, device.outlet_number, parent_device) - for device in power_switch + outlets.extend( + DINRelay(controller_name, parent_device, outlet) + for outlet in power_switch[0:] ) - add_devices(devices, True) + add_devices(outlets) class DINRelay(SwitchDevice): """Representation of a individual DIN III relay port.""" - def __init__(self, name, outletnumber, parent_device): + def __init__(self, controller_name, parent_device, outlet): """Initialize the DIN III Relay switch.""" + self._controller_name = controller_name self._parent_device = parent_device - self.controllername = name - self.outletnumber = outletnumber - self._outletname = '' - self._is_on = False + self._outlet = outlet + + self._outlet_number = self._outlet.outlet_number + self._name = self._outlet.description + self._state = self._outlet.state == 'ON' @property def name(self): """Return the display name of this relay.""" - return self._outletname + return '{}_{}'.format( + self._controller_name, + self._name + ) @property def is_on(self): """Return true if relay is on.""" - return self._is_on + return self._state @property def should_poll(self): @@ -100,41 +105,36 @@ class DINRelay(SwitchDevice): def turn_on(self, **kwargs): """Instruct the relay to turn on.""" - self._parent_device.turn_on(outlet=self.outletnumber) + self._outlet.on() def turn_off(self, **kwargs): """Instruct the relay to turn off.""" - self._parent_device.turn_off(outlet=self.outletnumber) + self._outlet.off() def update(self): """Trigger update for all switches on the parent device.""" self._parent_device.update() - self._is_on = ( - self._parent_device.statuslocal[self.outletnumber - 1][2] == 'ON' - ) - self._outletname = '{}_{}'.format( - self.controllername, - self._parent_device.statuslocal[self.outletnumber - 1][1] - ) + + outlet_status = self._parent_device.get_outlet_status( + self._outlet_number) + + self._name = outlet_status[1] + self._state = outlet_status[2] == 'ON' class DINRelayDevice(object): """Device representation for per device throttling.""" - def __init__(self, device): + def __init__(self, power_switch): """Initialize the DINRelay device.""" - self._device = device - self.statuslocal = None + self._power_switch = power_switch + self._statuslist = None - def turn_on(self, **kwargs): - """Instruct the relay to turn on.""" - self._device.on(**kwargs) - - def turn_off(self, **kwargs): - """Instruct the relay to turn off.""" - self._device.off(**kwargs) + def get_outlet_status(self, outlet_number): + """Get status of outlet from cached status list.""" + return self._statuslist[outlet_number - 1] @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Fetch new state data for this device.""" - self.statuslocal = self._device.statuslist() + self._statuslist = self._power_switch.statuslist() From 0687a457b1ff3ede52cdb99d77d768e27b2e91b3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 29 Aug 2017 15:44:36 +0200 Subject: [PATCH 065/277] Add counter component (#9146) --- homeassistant/components/counter.py | 220 +++++++++++++++++++++++++ homeassistant/components/services.yaml | 25 +++ tests/components/test_counter.py | 204 +++++++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 homeassistant/components/counter.py create mode 100644 tests/components/test_counter.py diff --git a/homeassistant/components/counter.py b/homeassistant/components/counter.py new file mode 100644 index 00000000000..64421306644 --- /dev/null +++ b/homeassistant/components/counter.py @@ -0,0 +1,220 @@ +""" +Component to count within automations. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/counter/ +""" +import asyncio +import logging +import os + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +ATTR_INITIAL = 'initial' +ATTR_STEP = 'step' + +CONF_INITIAL = 'initial' +CONF_STEP = 'step' + +DEFAULT_INITIAL = 0 +DEFAULT_STEP = 1 +DOMAIN = 'counter' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_DECREMENT = 'decrement' +SERVICE_INCREMENT = 'increment' +SERVICE_RESET = 'reset' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.Any({ + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): + cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, + }, None) + }) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def increment(hass, entity_id): + """Increment a counter.""" + hass.add_job(async_increment, hass, entity_id) + + +@callback +@bind_hass +def async_increment(hass, entity_id): + """Increment a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def decrement(hass, entity_id): + """Decrement a counter.""" + hass.add_job(async_decrement, hass, entity_id) + + +@callback +@bind_hass +def async_decrement(hass, entity_id): + """Decrement a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def reset(hass, entity_id): + """Reset a counter.""" + hass.add_job(async_reset, hass, entity_id) + + +@callback +@bind_hass +def async_reset(hass, entity_id): + """Reset a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up a counter.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg.get(CONF_INITIAL) + step = cfg.get(CONF_STEP) + icon = cfg.get(CONF_ICON) + + entities.append(Counter(object_id, name, initial, step, icon)) + + if not entities: + return False + + @asyncio.coroutine + def async_handler_service(service): + """Handle a call to the counter services.""" + target_counters = component.async_extract_from_service(service) + + if service.service == SERVICE_INCREMENT: + attr = 'async_increment' + elif service.service == SERVICE_DECREMENT: + attr = 'async_decrement' + elif service.service == SERVICE_RESET: + attr = 'async_reset' + + tasks = [getattr(counter, attr)() for counter in target_counters] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + 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_INCREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_DECREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_RESET, async_handler_service, + descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class Counter(Entity): + """Representation of a counter.""" + + def __init__(self, object_id, name, initial, step, icon): + """Initialize a counter.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._step = step + self._state = self._initial = initial + self._icon = icon + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the counter.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the counter.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_INITIAL: self._initial, + ATTR_STEP: self._step, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + # If not None, we got an initial value. + if self._state is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + self._state = state and state.state == state + + @asyncio.coroutine + def async_decrement(self): + """Decrement the counter.""" + self._state -= self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_increment(self): + """Increment a counter.""" + self._state += self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_reset(self): + """Reset a counter.""" + self._state = self._initial + yield from self.async_update_ha_state() diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 7315b6dc2d2..57820917cab 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -546,3 +546,28 @@ rflink: command: description: The command to be sent example: 'on' + +counter: + decrement: + description: Decrement a counter. + + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' + + increment: + description: Increment a counter. + + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' + + reset: + description: Reset a counter. + + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' diff --git a/tests/components/test_counter.py b/tests/components/test_counter.py new file mode 100644 index 00000000000..8dc04f0e76a --- /dev/null +++ b/tests/components/test_counter.py @@ -0,0 +1,204 @@ +"""The tests for the counter component.""" +# pylint: disable=protected-access +import asyncio +import unittest +import logging + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.counter import ( + DOMAIN, decrement, increment, reset, CONF_INITIAL, CONF_STEP, CONF_NAME, + CONF_ICON) +from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME) + +from tests.common import (get_test_home_assistant, mock_restore_cache) + +_LOGGER = logging.getLogger(__name__) + + +class TestCounter(unittest.TestCase): + """Test the counter component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_methods(self): + """Test increment, decrement, and reset methods.""" + config = { + DOMAIN: { + 'test_1': {}, + } + } + + assert setup_component(self.hass, 'counter', config) + + entity_id = 'counter.test_1' + + state = self.hass.states.get(entity_id) + self.assertEqual(0, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(1, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(2, int(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(1, int(state.state)) + + reset(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(0, int(state.state)) + + def test_methods_with_config(self): + """Test increment, decrement, and reset methods with configuration.""" + config = { + DOMAIN: { + 'test': { + CONF_NAME: 'Hello World', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + + entity_id = 'counter.test' + + state = self.hass.states.get(entity_id) + self.assertEqual(10, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(15, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(20, int(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(15, int(state.state)) + + def test_config_options(self): + """Test configuration options.""" + count_start = len(self.hass.states.entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + self.hass.block_till_done() + + _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) + + self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) + self.hass.block_till_done() + + state_1 = self.hass.states.get('counter.test_1') + state_2 = self.hass.states.get('counter.test_2') + + self.assertIsNotNone(state_1) + self.assertIsNotNone(state_2) + + self.assertEqual(0, int(state_1.state)) + self.assertNotIn(ATTR_ICON, state_1.attributes) + self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) + + self.assertEqual(10, int(state_2.state)) + self.assertEqual('Hello World', + state_2.attributes.get(ATTR_FRIENDLY_NAME)) + self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) + + +@asyncio.coroutine +def test_initial_state_overrules_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('counter.test1', '11'), + State('counter.test2', '-22'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': {}, + 'test2': { + CONF_INITIAL: 10, + }, + }}) + + state = hass.states.get('counter.test1') + assert state + assert int(state.state) == 0 + + state = hass.states.get('counter.test2') + assert state + assert int(state.state) == 10 + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': { + CONF_STEP: 5, + } + }}) + + state = hass.states.get('counter.test1') + assert state + assert int(state.state) == 0 From 3e0eb8763f94d544c6af1ffc867ff48ad9123354 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Tue, 29 Aug 2017 10:18:37 -0400 Subject: [PATCH 066/277] Support for season sensor (#8958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- homeassistant/components/sensor/season.py | 122 +++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_season.py | 183 ++++++++++++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 homeassistant/components/sensor/season.py create mode 100644 tests/components/sensor/test_season.py diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py new file mode 100644 index 00000000000..e02f3cac2b0 --- /dev/null +++ b/homeassistant/components/sensor/season.py @@ -0,0 +1,122 @@ +""" +Support for tracking which astronomical or meteorological season it is. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor/season/ +""" +import logging +from datetime import datetime + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_TYPE +from homeassistant.helpers.entity import Entity +import homeassistant.util as util + +REQUIREMENTS = ['ephem==3.7.6.0'] + +_LOGGER = logging.getLogger(__name__) + +NORTHERN = 'northern' +SOUTHERN = 'southern' +EQUATOR = 'equator' +STATE_SPRING = 'Spring' +STATE_SUMMER = 'Summer' +STATE_AUTUMN = 'Autumn' +STATE_WINTER = 'Winter' +TYPE_ASTRONOMICAL = 'astronomical' +TYPE_METEOROLOGICAL = 'meteorological' +VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] + +HEMISPHERE_SEASON_SWAP = {STATE_WINTER: STATE_SUMMER, + STATE_SPRING: STATE_AUTUMN, + STATE_AUTUMN: STATE_SPRING, + STATE_SUMMER: STATE_WINTER} + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Display the current season.""" + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return False + + latitude = util.convert(hass.config.latitude, float) + _type = config.get(CONF_TYPE) + + if latitude < 0: + hemisphere = SOUTHERN + elif latitude > 0: + hemisphere = NORTHERN + else: + hemisphere = EQUATOR + + _LOGGER.debug(_type) + add_devices([Season(hass, hemisphere, _type)]) + + return True + + +def get_season(date, hemisphere, season_tracking_type): + """Calculate the current season.""" + import ephem + + if hemisphere == 'equator': + return None + + if season_tracking_type == TYPE_ASTRONOMICAL: + spring_start = ephem.next_equinox(str(date.year)).datetime() + summer_start = ephem.next_solstice(str(date.year)).datetime() + autumn_start = ephem.next_equinox(spring_start).datetime() + winter_start = ephem.next_solstice(summer_start).datetime() + else: + spring_start = datetime(2017, 3, 1).replace(year=date.year) + summer_start = spring_start.replace(month=6) + autumn_start = spring_start.replace(month=9) + winter_start = spring_start.replace(month=12) + + if spring_start <= date < summer_start: + season = STATE_SPRING + elif summer_start <= date < autumn_start: + season = STATE_SUMMER + elif autumn_start <= date < winter_start: + season = STATE_AUTUMN + elif winter_start <= date or spring_start > date: + season = STATE_WINTER + + # If user is located in the southern hemisphere swap the season + if hemisphere == NORTHERN: + return season + return HEMISPHERE_SEASON_SWAP.get(season) + + +class Season(Entity): + """Representation of the current season.""" + + def __init__(self, hass, hemisphere, season_tracking_type): + """Initialize the season.""" + self.hass = hass + self.hemisphere = hemisphere + self.datetime = datetime.now() + self.type = season_tracking_type + self.season = get_season(self.datetime, self.hemisphere, self.type) + + @property + def name(self): + """Return the name.""" + return "Season" + + @property + def state(self): + """Return the current season.""" + return self.season + + def update(self): + """Update season.""" + self.datetime = datetime.now() + self.season = get_season(self.datetime, self.hemisphere, self.type) diff --git a/requirements_all.txt b/requirements_all.txt index 35170465cd9..34c1d5f1e72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -202,6 +202,9 @@ enocean==0.31 # homeassistant.components.sensor.envirophat # envirophat==0.0.6 +# homeassistant.components.sensor.season +ephem==3.7.6.0 + # homeassistant.components.keyboard_remote # evdev==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f286555833e..7695f83497b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,6 +39,9 @@ apns2==0.1.1 # homeassistant.components.sensor.dsmr dsmr_parser==0.8 +# homeassistant.components.sensor.season +ephem==3.7.6.0 + # homeassistant.components.climate.honeywell evohomeclient==0.2.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ba7a49cc7c0..8a215cd2873 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -70,6 +70,7 @@ TEST_REQUIREMENTS = ( 'restrictedpython', 'pyunifi', 'prometheus_client', + 'ephem' ) IGNORE_PACKAGES = ( diff --git a/tests/components/sensor/test_season.py b/tests/components/sensor/test_season.py new file mode 100644 index 00000000000..10e147bcff9 --- /dev/null +++ b/tests/components/sensor/test_season.py @@ -0,0 +1,183 @@ +"""The tests for the Season sensor platform.""" +# pylint: disable=protected-access +import unittest +from datetime import datetime + +import homeassistant.components.sensor.season as season + +from tests.common import get_test_home_assistant + + +# pylint: disable=invalid-name +class TestSeason(unittest.TestCase): + """Test the season platform.""" + + DEVICE = None + CONFIG_ASTRONOMICAL = {'type': 'astronomical'} + CONFIG_METEOROLOGICAL = {'type': 'meteorological'} + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICE = device + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_season_should_be_summer_northern_astonomical(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(summer_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_summer_northern_meteorological(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 8, 13, 0, 0) + current_season = season.get_season(summer_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_autumn_northern_astonomical(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 9, 23, 0, 0) + current_season = season.get_season(autumn_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_autumn_northern_meteorological(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(autumn_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_winter_northern_astonomical(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 12, 25, 0, 0) + current_season = season.get_season(winter_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_winter_northern_meteorological(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 12, 3, 0, 0) + current_season = season.get_season(winter_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_spring_northern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 4, 1, 0, 0) + current_season = season.get_season(spring_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_spring_northern_meteorological(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 3, 3, 0, 0) + current_season = season.get_season(spring_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_winter_southern_astonomical(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(winter_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_winter_southern_meteorological(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 8, 13, 0, 0) + current_season = season.get_season(winter_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_spring_southern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 9, 23, 0, 0) + current_season = season.get_season(spring_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_spring_southern_meteorological(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(spring_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_summer_southern_astonomical(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 12, 25, 0, 0) + current_season = season.get_season(summer_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_summer_southern_meteorological(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 12, 3, 0, 0) + current_season = season.get_season(summer_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_autumn_southern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + autumn_day = datetime(2017, 4, 1, 0, 0) + current_season = season.get_season(autumn_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_autumn_southern_meteorological(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 3, 3, 0, 0) + current_season = season.get_season(autumn_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_on_equator_results_in_none(self): + """Test that season should be unknown.""" + # A known day in summer if astronomical and northern + summer_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(summer_day, + season.EQUATOR, + season.TYPE_ASTRONOMICAL) + self.assertEqual(None, current_season) From aa8dd8fbdd5a97bbe11574ff7ab70e00c5b9ff91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 29 Aug 2017 16:20:26 +0200 Subject: [PATCH 067/277] Issue #6893 in rfxtrx (#9130) * Issue #6893 in rfxtrx * Update rfxtrx.py * rfxtrx issue --- homeassistant/components/rfxtrx.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index e3ffc2f24a8..e3226418ea9 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -77,10 +77,6 @@ def _valid_device(value, device_type): if not len(key) % 2 == 0: key = '0' + key - if get_rfx_object(key) is None: - raise vol.Invalid('Rfxtrx device {} is invalid: ' - 'Invalid device id for {}'.format(key, value)) - if device_type == 'sensor': config[key] = DEVICE_SCHEMA_SENSOR(device) elif device_type == 'binary_sensor': @@ -292,6 +288,9 @@ def get_devices_from_config(config, device, hass): devices = [] for packet_id, entity_info in config[CONF_DEVICES].items(): event = get_rfx_object(packet_id) + if event is None: + _LOGGER.error("Invalid device: %s", packet_id) + continue device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: continue From ee28b439b37c2a60c57c55902a7af11bafdefd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 29 Aug 2017 16:22:28 +0200 Subject: [PATCH 068/277] Refactor rfxtrx (#9117) * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor --- homeassistant/components/cover/rfxtrx.py | 4 +- homeassistant/components/light/rfxtrx.py | 4 +- homeassistant/components/rfxtrx.py | 47 +++++++++++++---------- homeassistant/components/switch/rfxtrx.py | 4 +- requirements_all.txt | 2 +- 5 files changed, 33 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index f599ea3ede1..0e28d3ef701 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the RFXtrx cover.""" import RFXtrx as rfxtrxmod - covers = rfxtrx.get_devices_from_config(config, RfxtrxCover, hass) + covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) add_devices_callback(covers) def cover_update(event): @@ -26,7 +26,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): not event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxCover, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index f831d6c04ce..9248b0131f1 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the RFXtrx platform.""" import RFXtrx as rfxtrxmod - lights = rfxtrx.get_devices_from_config(config, RfxtrxLight, hass) + lights = rfxtrx.get_devices_from_config(config, RfxtrxLight) add_devices(lights) def light_update(event): @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): not event.device.known_to_be_dimmable: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxLight, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxLight) if new_device: add_devices([new_device]) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index e3226418ea9..259f8fa8ac6 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -4,6 +4,7 @@ Support for RFXtrx components. For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ + import logging from collections import OrderedDict import voluptuous as vol @@ -11,13 +12,14 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ATTR_ENTITY_ID, TEMP_CELSIUS, CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF ) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyRFXtrx==0.19.0'] +REQUIREMENTS = ['pyRFXtrx==0.20.0'] DOMAIN = 'rfxtrx' @@ -54,7 +56,7 @@ DATA_TYPES = OrderedDict([ RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} _LOGGER = logging.getLogger(__name__) -RFXOBJECT = None +RFXOBJECT = 'rfxobject' def _valid_device(value, device_type): @@ -167,24 +169,24 @@ def setup(hass, config): # Try to load the RFXtrx module. import RFXtrx as rfxtrxmod - # Init the rfxtrx module. - global RFXOBJECT - device = config[DOMAIN][ATTR_DEVICE] debug = config[DOMAIN][ATTR_DEBUG] dummy_connection = config[DOMAIN][ATTR_DUMMY] if dummy_connection: - RFXOBJECT =\ - rfxtrxmod.Connect(device, handle_receive, debug=debug, + hass.data[RFXOBJECT] =\ + rfxtrxmod.Connect(device, None, debug=debug, transport_protocol=rfxtrxmod.DummyTransport2) else: - RFXOBJECT = rfxtrxmod.Connect(device, handle_receive, debug=debug) + hass.data[RFXOBJECT] = rfxtrxmod.Connect(device, None, debug=debug) + + def _start_rfxtrx(event): + hass.data[RFXOBJECT].event_callback = handle_receive + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" - RFXOBJECT.close_connection() - + hass.data[RFXOBJECT].close_connection() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) return True @@ -281,7 +283,7 @@ def find_possible_pt2262_device(device_id): return None -def get_devices_from_config(config, device, hass): +def get_devices_from_config(config, device): """Read rfxtrx configuration.""" signal_repetitions = config[CONF_SIGNAL_REPETITIONS] @@ -302,13 +304,12 @@ def get_devices_from_config(config, device, hass): new_device = device(entity_info[ATTR_NAME], event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device devices.append(new_device) return devices -def get_new_device(event, config, device, hass): +def get_new_device(event, config, device): """Add entity if not exist and the automatic_add is True.""" device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: @@ -329,7 +330,6 @@ def get_new_device(event, config, device, hass): signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device return new_device @@ -437,31 +437,36 @@ class RfxtrxDevice(Entity): if command == "turn_on": for _ in range(self.signal_repetitions): - self._event.device.send_on(RFXOBJECT.transport) + self._event.device.send_on(self.hass.data[RFXOBJECT] + .transport) self._state = True elif command == "dim": for _ in range(self.signal_repetitions): - self._event.device.send_dim(RFXOBJECT.transport, - brightness) + self._event.device.send_dim(self.hass.data[RFXOBJECT] + .transport, brightness) self._state = True elif command == 'turn_off': for _ in range(self.signal_repetitions): - self._event.device.send_off(RFXOBJECT.transport) + self._event.device.send_off(self.hass.data[RFXOBJECT] + .transport) self._state = False self._brightness = 0 elif command == "roll_up": for _ in range(self.signal_repetitions): - self._event.device.send_open(RFXOBJECT.transport) + self._event.device.send_open(self.hass.data[RFXOBJECT] + .transport) elif command == "roll_down": for _ in range(self.signal_repetitions): - self._event.device.send_close(RFXOBJECT.transport) + self._event.device.send_close(self.hass.data[RFXOBJECT] + .transport) elif command == "stop_roll": for _ in range(self.signal_repetitions): - self._event.device.send_stop(RFXOBJECT.transport) + self._event.device.send_stop(self.hass.data[RFXOBJECT] + .transport) self.schedule_update_ha_state() diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 36044f5f168..1361d22de18 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): import RFXtrx as rfxtrxmod # Add switch from config file - switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch, hass) + switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch) add_devices_callback(switches) def switch_update(event): @@ -31,7 +31,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch) if new_device: add_devices_callback([new_device]) diff --git a/requirements_all.txt b/requirements_all.txt index 34c1d5f1e72..ce2f2d5f560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -533,7 +533,7 @@ pyCEC==0.4.13 pyHS100==0.2.4.2 # homeassistant.components.rfxtrx -pyRFXtrx==0.19.0 +pyRFXtrx==0.20.0 # homeassistant.components.switch.dlink pyW215==0.5.1 From b8d737c0cc7dda6113034ae0edc86c2d80a3d3c5 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 29 Aug 2017 17:10:28 +0200 Subject: [PATCH 069/277] Upgrade pymysensors to 0.11.1 (#9212) --- homeassistant/components/mysensors.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 210c5773c53..62fecddb8c4 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component from homeassistant.setup import setup_component -REQUIREMENTS = ['pymysensors==0.11.0'] +REQUIREMENTS = ['pymysensors==0.11.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ce2f2d5f560..771b6475be6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -661,7 +661,7 @@ pymodbus==1.3.1 pymyq==0.0.8 # homeassistant.components.mysensors -pymysensors==0.11.0 +pymysensors==0.11.1 # homeassistant.components.lock.nello pynello==1.5 From 81a00bf3f1ba66d9ab00bf323066997f6c564af5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Aug 2017 08:10:38 -0700 Subject: [PATCH 070/277] Lint Sonarr tests --- tests/components/sensor/test_sonarr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/sensor/test_sonarr.py b/tests/components/sensor/test_sonarr.py index bd0011597af..9e2050e850c 100644 --- a/tests/components/sensor/test_sonarr.py +++ b/tests/components/sensor/test_sonarr.py @@ -815,7 +815,7 @@ class TestSonarrSetup(unittest.TestCase): @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) def test_system_status(self, req_mock): - """Test getting system status""" + """Test getting system status.""" config = { 'platform': 'sonarr', 'api_key': 'foo', From 33c906c20aa8ad62f19c974001ffa906759577f2 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Tue, 29 Aug 2017 08:34:19 -0700 Subject: [PATCH 071/277] Abode push events and lock, cover, and switch components (#9095) * Updated abodepy version to 0.7.1 * Refactored to use AbodeDevice. Added Abode Lock device. * Added push updates to abode devices. * Upgraded to 0.7.2 after finding issue with callbacks. * Refactored to use AbodeDevice. Added Abode Lock device. * Added push updates to abode devices. * Upgraded to 0.7.2 after finding issue with callbacks. * Bumped version to 0.8.2. Modified code to work with new constants and properties. Added cover and switch. * Fixed hound violations. * Updated to 0.8.3 to fix small bug with standby mode. Fixed comment in cover/abode.py. * Fix lint issues * Removed excessive logging. Moved device callback registration to async_added_to_hass. Moved abode controller from global into hass data. * Removed explicit None from dict.get() * Move device class into the constructor. * Changed constant name to platforms. * Changes as requested. * Removing stray blank line. * Added blank line of which I'm not sure how it was removed. * Updated version to 0.9.0. Fixed motion sensor. Added power_switch_meter device type. * Update abode.py * fix lint --- homeassistant/components/abode.py | 86 +++++++++++++++---- .../components/alarm_control_panel/abode.py | 50 +++++------ .../components/binary_sensor/abode.py | 76 ++++++---------- homeassistant/components/cover/abode.py | 49 +++++++++++ homeassistant/components/lock/abode.py | 49 +++++++++++ homeassistant/components/switch/abode.py | 53 ++++++++++++ requirements_all.txt | 2 +- 7 files changed, 270 insertions(+), 95 deletions(-) create mode 100644 homeassistant/components/cover/abode.py create mode 100644 homeassistant/components/lock/abode.py create mode 100644 homeassistant/components/switch/abode.py diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 677fcab4f5d..c8d4ee67d49 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -4,15 +4,20 @@ This component provides basic support for Abode Home Security system. For more details about this component, please refer to the documentation at https://home-assistant.io/components/abode/ """ +import asyncio import logging import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.const import (ATTR_ATTRIBUTION, + CONF_USERNAME, CONF_PASSWORD, + CONF_NAME, EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.7.1'] +REQUIREMENTS = ['abodepy==0.9.0'] _LOGGER = logging.getLogger(__name__) @@ -20,8 +25,7 @@ CONF_ATTRIBUTION = "Data provided by goabode.com" DOMAIN = 'abode' DEFAULT_NAME = 'Abode' -DATA_ABODE = 'data_abode' -DEFAULT_ENTITY_NAMESPACE = 'abode' +DATA_ABODE = 'abode' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' @@ -34,19 +38,21 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +ABODE_PLATFORMS = [ + 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover' +] + def setup(hass, config): """Set up Abode component.""" + import abodepy + conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) try: - data = AbodeData(username, password) - hass.data[DATA_ABODE] = data - - for component in ['binary_sensor', 'alarm_control_panel']: - discovery.load_platform(hass, component, DOMAIN, {}, config) + hass.data[DATA_ABODE] = abode = abodepy.Abode(username, password) except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) @@ -58,18 +64,62 @@ def setup(hass, config): notification_id=NOTIFICATION_ID) return False + for platform in ABODE_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + def logout(event): + """Logout of Abode.""" + abode.stop_listener() + abode.logout() + _LOGGER.info("Logged out of Abode") + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) + + def startup(event): + """Listen for push events.""" + abode.start_listener() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + return True -class AbodeData: - """Shared Abode data.""" +class AbodeDevice(Entity): + """Representation of an Abode device.""" - def __init__(self, username, password): - """Initialize Abode oject.""" - import abodepy + def __init__(self, controller, device): + """Initialize a sensor for Abode device.""" + self._controller = controller + self._device = device - self.abode = abodepy.Abode(username, password) - self.devices = self.abode.get_devices() + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + self.hass.async_add_job( + self._controller.register, self._device, + self._update_callback + ) - _LOGGER.debug("Abode Security set up with %s devices", - len(self.devices)) + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_low': self._device.battery_low, + 'no_response': self._device.no_response + } + + def _update_callback(self, device): + """Update the device state.""" + self.schedule_update_ha_state() diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py index 7d7ce931c20..7a615ffc7bf 100644 --- a/homeassistant/components/alarm_control_panel/abode.py +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -6,10 +6,12 @@ https://home-assistant.io/components/alarm_control_panel.abode/ """ import logging -from homeassistant.components.abode import (DATA_ABODE, DEFAULT_NAME) -from homeassistant.const import (STATE_ALARM_ARMED_AWAY, +from homeassistant.components.abode import ( + AbodeDevice, DATA_ABODE, DEFAULT_NAME, CONF_ATTRIBUTION) +from homeassistant.components.alarm_control_panel import (AlarmControlPanel) +from homeassistant.const import (ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) -import homeassistant.components.alarm_control_panel as alarm + DEPENDENCIES = ['abode'] @@ -20,30 +22,19 @@ ICON = 'mdi:security' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - data = hass.data.get(DATA_ABODE) + abode = hass.data[DATA_ABODE] - add_devices([AbodeAlarm(hass, data, data.abode.get_alarm())]) + add_devices([AbodeAlarm(abode, abode.get_alarm())]) -class AbodeAlarm(alarm.AlarmControlPanel): +class AbodeAlarm(AbodeDevice, AlarmControlPanel): """An alarm_control_panel implementation for Abode.""" - def __init__(self, hass, data, device): + def __init__(self, controller, device): """Initialize the alarm control panel.""" - super(AbodeAlarm, self).__init__() - self._device = device + AbodeDevice.__init__(self, controller, device) self._name = "{0}".format(DEFAULT_NAME) - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def icon(self): """Return icon.""" @@ -52,11 +43,11 @@ class AbodeAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._device.mode == "standby": + if self._device.is_standby: state = STATE_ALARM_DISARMED - elif self._device.mode == "away": + elif self._device.is_away: state = STATE_ALARM_ARMED_AWAY - elif self._device.mode == "home": + elif self._device.is_home: state = STATE_ALARM_ARMED_HOME else: state = None @@ -65,18 +56,21 @@ class AbodeAlarm(alarm.AlarmControlPanel): def alarm_disarm(self, code=None): """Send disarm command.""" self._device.set_standby() - self.schedule_update_ha_state() def alarm_arm_home(self, code=None): """Send arm home command.""" self._device.set_home() - self.schedule_update_ha_state() def alarm_arm_away(self, code=None): """Send arm away command.""" self._device.set_away() - self.schedule_update_ha_state() - def update(self): - """Update the device state.""" - self._device.refresh() + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_backup': self._device.battery, + 'cellular_backup': self._device.is_cellular + } diff --git a/homeassistant/components/binary_sensor/abode.py b/homeassistant/components/binary_sensor/abode.py index 9abff53026d..d3b0d662a94 100644 --- a/homeassistant/components/binary_sensor/abode.py +++ b/homeassistant/components/binary_sensor/abode.py @@ -6,76 +6,56 @@ https://home-assistant.io/components/binary_sensor.abode/ """ import logging -from homeassistant.components.abode import (CONF_ATTRIBUTION, DATA_ABODE) -from homeassistant.const import (ATTR_ATTRIBUTION) -from homeassistant.components.binary_sensor import (BinarySensorDevice) +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.binary_sensor import BinarySensorDevice + DEPENDENCIES = ['abode'] _LOGGER = logging.getLogger(__name__) -# Sensor types: Name, device_class -SENSOR_TYPES = { - 'Door Contact': 'opening', - 'Motion Camera': 'motion', -} - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - data = hass.data.get(DATA_ABODE) + abode = hass.data[DATA_ABODE] + + device_types = map_abode_device_class().keys() sensors = [] - for sensor in data.devices: - _LOGGER.debug('Sensor type %s', sensor.type) - if sensor.type in ['Door Contact', 'Motion Camera']: - sensors.append(AbodeBinarySensor(hass, data, sensor)) + for sensor in abode.get_devices(type_filter=device_types): + sensors.append(AbodeBinarySensor(abode, sensor)) - _LOGGER.debug('Adding %d sensors', len(sensors)) add_devices(sensors) -class AbodeBinarySensor(BinarySensorDevice): +def map_abode_device_class(): + """Map Abode device types to Home Assistant binary sensor class.""" + import abodepy.helpers.constants as CONST + + return { + CONST.DEVICE_GLASS_BREAK: 'connectivity', + CONST.DEVICE_KEYPAD: 'connectivity', + CONST.DEVICE_DOOR_CONTACT: 'opening', + CONST.DEVICE_STATUS_DISPLAY: 'connectivity', + CONST.DEVICE_MOTION_CAMERA: 'connectivity', + CONST.DEVICE_WATER_SENSOR: 'moisture' + } + + +class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): """A binary sensor implementation for Abode device.""" - def __init__(self, hass, data, device): + def __init__(self, controller, device): """Initialize a sensor for Abode device.""" - super(AbodeBinarySensor, self).__init__() - self._device = device - - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the sensor.""" - return "{0} {1}".format(self._device.type, self._device.name) + AbodeDevice.__init__(self, controller, device) + self._device_class = map_abode_device_class().get(self._device.type) @property def is_on(self): """Return True if the binary sensor is on.""" - if self._device.type == 'Door Contact': - return self._device.status != 'Closed' - elif self._device.type == 'Motion Camera': - return self._device.get_value('motion_event') == '1' + return self._device.is_on @property def device_class(self): """Return the class of the binary sensor.""" - return SENSOR_TYPES.get(self._device.type) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {} - attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - attrs['device_id'] = self._device.device_id - attrs['battery_low'] = self._device.battery_low - - return attrs - - def update(self): - """Update the device state.""" - self._device.refresh() + return self._device_class diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py new file mode 100644 index 00000000000..b09c9e5e007 --- /dev/null +++ b/homeassistant/components/cover/abode.py @@ -0,0 +1,49 @@ +""" +This component provides HA cover support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.cover import CoverDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode cover devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + sensors = [] + for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)): + sensors.append(AbodeCover(abode, sensor)) + + add_devices(sensors) + + +class AbodeCover(AbodeDevice, CoverDevice): + """Representation of an Abode cover.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._device.is_open is False + + def close_cover(self): + """Issue close command to cover.""" + self._device.close_cover() + + def open_cover(self): + """Issue open command to cover.""" + self._device.open_cover() diff --git a/homeassistant/components/lock/abode.py b/homeassistant/components/lock/abode.py new file mode 100644 index 00000000000..aad720e0d7d --- /dev/null +++ b/homeassistant/components/lock/abode.py @@ -0,0 +1,49 @@ +""" +This component provides HA lock support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.lock import LockDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode lock devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + sensors = [] + for sensor in abode.get_devices(type_filter=(CONST.DEVICE_DOOR_LOCK)): + sensors.append(AbodeLock(abode, sensor)) + + add_devices(sensors) + + +class AbodeLock(AbodeDevice, LockDevice): + """Representation of an Abode lock.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + def lock(self, **kwargs): + """Lock the device.""" + self._device.lock() + + def unlock(self, **kwargs): + """Unlock the device.""" + self._device.unlock() + + @property + def is_locked(self): + """Return true if device is on.""" + return self._device.is_locked diff --git a/homeassistant/components/switch/abode.py b/homeassistant/components/switch/abode.py new file mode 100644 index 00000000000..bed0b9c0b60 --- /dev/null +++ b/homeassistant/components/switch/abode.py @@ -0,0 +1,53 @@ +""" +This component provides HA switch support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.switch import SwitchDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode switch devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + device_types = [ + CONST.DEVICE_POWER_SWITCH_SENSOR, + CONST.DEVICE_POWER_SWITCH_METER] + + sensors = [] + for sensor in abode.get_devices(type_filter=device_types): + sensors.append(AbodeSwitch(abode, sensor)) + + add_devices(sensors) + + +class AbodeSwitch(AbodeDevice, SwitchDevice): + """Representation of an Abode switch.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on diff --git a/requirements_all.txt b/requirements_all.txt index 771b6475be6..b357f9ffc53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -39,7 +39,7 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.7.1 +abodepy==0.9.0 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.2 From 0b58d5405e182746af97530f6cae0528d1d07146 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Aug 2017 13:40:08 -0700 Subject: [PATCH 072/277] Add cloud auth support (#9208) * Add initial cloud auth * Move hass.data to a dict * Move mode into helper * Fix bugs afte refactor * Add tests * Clean up scripts file after test config * Lint * Update __init__.py --- homeassistant/components/cloud/__init__.py | 49 +++ homeassistant/components/cloud/cloud_api.py | 297 +++++++++++++++++ homeassistant/components/cloud/const.py | 14 + homeassistant/components/cloud/http_api.py | 119 +++++++ homeassistant/components/cloud/util.py | 10 + tests/common.py | 2 +- tests/components/cloud/__init__.py | 1 + tests/components/cloud/test_cloud_api.py | 352 ++++++++++++++++++++ tests/components/cloud/test_http_api.py | 157 +++++++++ tests/test_config.py | 6 + tests/test_util/aiohttp.py | 1 + 11 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/cloud/__init__.py create mode 100644 homeassistant/components/cloud/cloud_api.py create mode 100644 homeassistant/components/cloud/const.py create mode 100644 homeassistant/components/cloud/http_api.py create mode 100644 homeassistant/components/cloud/util.py create mode 100644 tests/components/cloud/__init__.py create mode 100644 tests/components/cloud/test_cloud_api.py create mode 100644 tests/components/cloud/test_http_api.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 00000000000..8804f6d113f --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,49 @@ +"""Component to integrate the Home Assistant cloud.""" +import asyncio +import logging + +import voluptuous as vol + +from . import http_api, cloud_api +from .const import DOMAIN + + +DEPENDENCIES = ['http'] +CONF_MODE = 'mode' +MODE_DEV = 'development' +MODE_STAGING = 'staging' +MODE_PRODUCTION = 'production' +DEFAULT_MODE = MODE_DEV + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + }), +}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + mode = MODE_PRODUCTION + + if DOMAIN in config: + mode = config[DOMAIN].get(CONF_MODE) + + if mode != 'development': + _LOGGER.error('Only development mode is currently allowed.') + return False + + data = hass.data[DOMAIN] = { + 'mode': mode + } + + cloud = yield from cloud_api.async_load_auth(hass) + + if cloud is not None: + data['cloud'] = cloud + + yield from http_api.async_setup(hass) + return True diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 00000000000..6429da14516 --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,297 @@ +"""Package to offer tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +import json +import logging +import os +from urllib.parse import urljoin + +import aiohttp +import async_timeout + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.dt import utcnow + +from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +URL_CREATE_TOKEN = 'o/token/' +URL_REVOKE_TOKEN = 'o/revoke_token/' +URL_ACCOUNT = 'account.json' + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + def __init__(self, reason=None, status=None): + """Initialize a cloud error.""" + super().__init__(reason) + self.status = status + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UnknownError(CloudError): + """Raised when an unknown error occurred.""" + + +@asyncio.coroutine +def async_load_auth(hass): + """Load authentication from disk and verify it.""" + auth = yield from hass.async_add_job(_read_auth, hass) + + if not auth: + return None + + cloud = Cloud(hass, auth) + + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + auth_check = yield from cloud.async_refresh_account_info() + + if not auth_check: + _LOGGER.error('Unable to validate credentials.') + return None + + return cloud + + except asyncio.TimeoutError: + _LOGGER.error('Unable to reach server to validate credentials.') + return None + + +@asyncio.coroutine +def async_login(hass, username, password, scope=None): + """Get a token using a username and password. + + Returns a coroutine. + """ + data = { + 'grant_type': 'password', + 'username': username, + 'password': password + } + if scope is not None: + data['scope'] = scope + + auth = yield from _async_get_token(hass, data) + + yield from hass.async_add_job(_write_auth, hass, auth) + + return Cloud(hass, auth) + + +@asyncio.coroutine +def _async_get_token(hass, data): + """Get a new token and return it as a dictionary. + + Raises exceptions when errors occur: + - Unauthenticated + - UnknownError + """ + session = async_get_clientsession(hass) + auth = aiohttp.BasicAuth(*_client_credentials(hass)) + + try: + req = yield from session.post( + _url(hass, URL_CREATE_TOKEN), + data=data, + auth=auth + ) + + if req.status == 401: + _LOGGER.error('Cloud login failed: %d', req.status) + raise Unauthenticated(status=req.status) + elif req.status != 200: + _LOGGER.error('Cloud login failed: %d', req.status) + raise UnknownError(status=req.status) + + response = yield from req.json() + response['expires_at'] = \ + (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() + + return response + + except aiohttp.ClientError: + raise UnknownError() + + +class Cloud: + """Store Hass Cloud info.""" + + def __init__(self, hass, auth): + """Initialize Hass cloud info object.""" + self.hass = hass + self.auth = auth + self.account = None + + @property + def access_token(self): + """Return access token.""" + return self.auth['access_token'] + + @property + def refresh_token(self): + """Get refresh token.""" + return self.auth['refresh_token'] + + @asyncio.coroutine + def async_refresh_account_info(self): + """Refresh the account info.""" + req = yield from self.async_request('get', URL_ACCOUNT) + + if req.status != 200: + return False + + self.account = yield from req.json() + return True + + @asyncio.coroutine + def async_refresh_access_token(self): + """Get a token using a refresh token.""" + try: + self.auth = yield from _async_get_token(self.hass, { + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + }) + + yield from self.hass.async_add_job( + _write_auth, self.hass, self.auth) + + return True + except CloudError: + return False + + @asyncio.coroutine + def async_revoke_access_token(self): + """Revoke active access token.""" + session = async_get_clientsession(self.hass) + client_id, client_secret = _client_credentials(self.hass) + data = { + 'token': self.access_token, + 'client_id': client_id, + 'client_secret': client_secret + } + try: + req = yield from session.post( + _url(self.hass, URL_REVOKE_TOKEN), + data=data, + ) + + if req.status != 200: + _LOGGER.error('Cloud logout failed: %d', req.status) + raise UnknownError(status=req.status) + + self.auth = None + yield from self.hass.async_add_job( + _write_auth, self.hass, None) + + except aiohttp.ClientError: + raise UnknownError() + + @asyncio.coroutine + def async_request(self, method, path, **kwargs): + """Make a request to Home Assistant cloud. + + Will refresh the token if necessary. + """ + session = async_get_clientsession(self.hass) + url = _url(self.hass, path) + + if 'headers' not in kwargs: + kwargs['headers'] = {} + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + request = yield from session.request(method, url, **kwargs) + + if request.status != 403: + return request + + # Maybe token expired. Try refreshing it. + reauth = yield from self.async_refresh_access_token() + + if not reauth: + return request + + # Release old connection back to the pool. + yield from request.release() + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + # If we are not already fetching the account info, + # refresh the account info. + + if path != URL_ACCOUNT: + yield from self.async_refresh_account_info() + + request = yield from session.request(method, url, **kwargs) + + return request + + +def _read_auth(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_auth(hass, data): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if data is None: + content.pop(mode, None) + else: + content[mode] = data + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _client_credentials(hass): + """Get the client credentials. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] + + +def _url(hass, path): + """Generate a url for the cloud. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py new file mode 100644 index 00000000000..f55a4be21a2 --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,14 @@ +"""Constants for the cloud component.""" +DOMAIN = 'cloud' +REQUEST_TIMEOUT = 10 +AUTH_FILE = '.cloud' + +SERVERS = { + 'development': { + 'host': 'http://localhost:8000', + 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', + 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' + 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' + 'VBJrRyfgTVd43kbrEQtuOiaUpK') + } +} diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py new file mode 100644 index 00000000000..661cc8a7ba1 --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,119 @@ +"""The HTTP api to control the cloud integration.""" +import asyncio +import logging + +import voluptuous as vol +import async_timeout + +from homeassistant.components.http import HomeAssistantView + +from . import cloud_api +from .const import DOMAIN, REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass): + """Initialize the HTTP api.""" + hass.http.register_view(CloudLoginView) + hass.http.register_view(CloudLogoutView) + hass.http.register_view(CloudAccountView) + + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str, + }) + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + try: + data = yield from request.json() + except ValueError: + _LOGGER.error('Login with invalid JSON') + return self.json_message('Invalid JSON.', 400) + + try: + self.schema(data) + except vol.Invalid as err: + _LOGGER.error('Login with invalid formatted data') + return self.json_message( + 'Message format incorrect: {}'.format(err), 400) + + hass = request.app['hass'] + phase = 1 + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + cloud = yield from cloud_api.async_login( + hass, data['username'], data['password']) + + phase += 1 + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from cloud.async_refresh_account_info() + + except cloud_api.Unauthenticated: + return self.json_message( + 'Authentication failed (phase {}).'.format(phase), 401) + except cloud_api.UnknownError: + return self.json_message( + 'Unknown error occurred (phase {}).'.format(phase), 500) + except asyncio.TimeoutError: + return self.json_message( + 'Unable to reach Home Assistant cloud ' + '(phase {}).'.format(phase), 502) + + hass.data[DOMAIN]['cloud'] = cloud + return self.json(cloud.account) + + +class CloudLogoutView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/logout' + name = 'api:cloud:logout' + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from \ + hass.data[DOMAIN]['cloud'].async_revoke_access_token() + + hass.data[DOMAIN].pop('cloud') + + return self.json({ + 'result': 'ok', + }) + except asyncio.TimeoutError: + return self.json_message("Could not reach the server.", 502) + except cloud_api.UnknownError as err: + return self.json_message( + "Error communicating with the server ({}).".format(err.status), + 502) + + +class CloudAccountView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/account' + name = 'api:cloud:account' + + @asyncio.coroutine + def get(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + + if 'cloud' not in hass.data[DOMAIN]: + return self.json_message('Not logged in', 400) + + return self.json(hass.data[DOMAIN]['cloud'].account) diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py new file mode 100644 index 00000000000..ec5445f0638 --- /dev/null +++ b/homeassistant/components/cloud/util.py @@ -0,0 +1,10 @@ +"""Utilities for the cloud integration.""" +from .const import DOMAIN + + +def get_mode(hass): + """Return the current mode of the cloud component. + + Async friendly. + """ + return hass.data[DOMAIN]['mode'] diff --git a/tests/common.py b/tests/common.py index 5fdec2fc411..f0d6a5bd057 100644 --- a/tests/common.py +++ b/tests/common.py @@ -119,7 +119,7 @@ def async_test_home_assistant(loop): def async_add_job(target, *args): """Add a magic mock.""" if isinstance(target, Mock): - return mock_coro(target()) + return mock_coro(target(*args)) return orig_async_add_job(target, *args) hass.async_add_job = async_add_job diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py new file mode 100644 index 00000000000..707e49f670f --- /dev/null +++ b/tests/components/cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the cloud component.""" diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py new file mode 100644 index 00000000000..11c396daf05 --- /dev/null +++ b/tests/components/cloud/test_cloud_api.py @@ -0,0 +1,352 @@ +"""Tests for the tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +from unittest.mock import patch +from urllib.parse import urljoin + +import aiohttp +import pytest + +from homeassistant.components.cloud import DOMAIN, cloud_api, const +import homeassistant.util.dt as dt_util + +from tests.common import mock_coro + + +MOCK_AUTH = { + "access_token": "jvCHxpTu2nfORLBRgQY78bIAoK4RPa", + "expires_at": "2017-08-29T05:33:28.266048+00:00", + "expires_in": 86400, + "refresh_token": "C4wR1mgb03cs69EeiFgGOBC8mMQC5Q", + "scope": "", + "token_type": "Bearer" +} + + +def url(path): + """Create a url.""" + return urljoin(const.SERVERS['development']['host'], path) + + +@pytest.fixture +def cloud_hass(hass): + """Fixture to return a hass instance with cloud mode set.""" + hass.data[DOMAIN] = {'mode': 'development'} + return hass + + +@pytest.fixture +def mock_write(): + """Mock reading authentication.""" + with patch.object(cloud_api, '_write_auth') as mock: + yield mock + + +@pytest.fixture +def mock_read(): + """Mock writing authentication.""" + with patch.object(cloud_api, '_read_auth') as mock: + yield mock + + +@asyncio.coroutine +def test_async_login_invalid_auth(cloud_hass, aioclient_mock, mock_write): + """Test trying to login with invalid credentials.""" + aioclient_mock.post(url('o/token/'), status=401) + with pytest.raises(cloud_api.Unauthenticated): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login_cloud_error(cloud_hass, aioclient_mock, mock_write): + """Test exception in cloud while logging in.""" + aioclient_mock.post(url('o/token/'), status=500) + with pytest.raises(cloud_api.UnknownError): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login_client_error(cloud_hass, aioclient_mock, mock_write): + """Test client error while logging in.""" + aioclient_mock.post(url('o/token/'), exc=aiohttp.ClientError) + with pytest.raises(cloud_api.UnknownError): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login(cloud_hass, aioclient_mock, mock_write): + """Test logging in.""" + aioclient_mock.post(url('o/token/'), json={ + 'expires_in': 10 + }) + now = dt_util.utcnow() + with patch('homeassistant.components.cloud.cloud_api.utcnow', + return_value=now): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 1 + result_hass, result_data = mock_write.mock_calls[0][1] + assert result_hass is cloud_hass + assert result_data == { + 'expires_in': 10, + 'expires_at': (now + timedelta(seconds=10)).isoformat() + } + + +@asyncio.coroutine +def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): + """Test loading authentication with no stored auth.""" + mock_read.return_value = None + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_timeout_during_verification(cloud_hass, mock_read): + """Test loading authentication with timeout during verification.""" + mock_read.return_value = MOCK_AUTH + + with patch.object(cloud_api.Cloud, 'async_refresh_account_info', + side_effect=asyncio.TimeoutError): + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_verification_failed_500(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with verify request getting 500.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=500) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_401(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh needed which gets 401.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), status=401) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_500(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh needed which gets 500.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), status=500) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_timeout(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), exc=asyncio.TimeoutError) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_succeeds(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(True)) as mock_refresh: + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + assert len(mock_refresh.mock_calls) == 1 + + +@asyncio.coroutine +def test_load_auth_token(cloud_hass, mock_read, aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), json={ + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + }) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is not None + assert result.account == { + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + } + assert result.auth == MOCK_AUTH + + +def test_cloud_properties(): + """Test Cloud class properties.""" + cloud = cloud_api.Cloud(None, MOCK_AUTH) + assert cloud.access_token == MOCK_AUTH['access_token'] + assert cloud.refresh_token == MOCK_AUTH['refresh_token'] + + +@asyncio.coroutine +def test_cloud_refresh_account_info(cloud_hass, aioclient_mock): + """Test refreshing account info.""" + aioclient_mock.get(url('account.json'), json={ + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + }) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + assert cloud.account is None + result = yield from cloud.async_refresh_account_info() + assert result + assert cloud.account == { + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + } + + +@asyncio.coroutine +def test_cloud_refresh_account_info_500(cloud_hass, aioclient_mock): + """Test refreshing account info and getting 500.""" + aioclient_mock.get(url('account.json'), status=500) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + assert cloud.account is None + result = yield from cloud.async_refresh_account_info() + assert not result + assert cloud.account is None + + +@asyncio.coroutine +def test_cloud_refresh_token(cloud_hass, aioclient_mock, mock_write): + """Test refreshing access token.""" + aioclient_mock.post(url('o/token/'), json={ + 'access_token': 'refreshed', + 'expires_in': 10 + }) + now = dt_util.utcnow() + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch('homeassistant.components.cloud.cloud_api.utcnow', + return_value=now): + result = yield from cloud.async_refresh_access_token() + assert result + assert cloud.auth == { + 'access_token': 'refreshed', + 'expires_in': 10, + 'expires_at': (now + timedelta(seconds=10)).isoformat() + } + assert len(mock_write.mock_calls) == 1 + write_hass, write_data = mock_write.mock_calls[0][1] + assert write_hass is cloud_hass + assert write_data == cloud.auth + + +@asyncio.coroutine +def test_cloud_refresh_token_unknown_error(cloud_hass, aioclient_mock, + mock_write): + """Test refreshing access token.""" + aioclient_mock.post(url('o/token/'), status=500) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + result = yield from cloud.async_refresh_access_token() + assert not result + assert cloud.auth == MOCK_AUTH + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_revoke_token(cloud_hass, aioclient_mock, mock_write): + """Test revoking access token.""" + aioclient_mock.post(url('o/revoke_token/')) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + yield from cloud.async_revoke_access_token() + assert cloud.auth is None + assert len(mock_write.mock_calls) == 1 + write_hass, write_data = mock_write.mock_calls[0][1] + assert write_hass is cloud_hass + assert write_data is None + + +@asyncio.coroutine +def test_cloud_revoke_token_invalid_client_creds(cloud_hass, aioclient_mock, + mock_write): + """Test revoking access token with invalid client credentials.""" + aioclient_mock.post(url('o/revoke_token/'), status=401) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with pytest.raises(cloud_api.UnknownError): + yield from cloud.async_revoke_access_token() + assert cloud.auth is not None + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_revoke_token_request_error(cloud_hass, aioclient_mock, + mock_write): + """Test revoking access token with invalid client credentials.""" + aioclient_mock.post(url('o/revoke_token/'), exc=aiohttp.ClientError) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with pytest.raises(cloud_api.UnknownError): + yield from cloud.async_revoke_access_token() + assert cloud.auth is not None + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_request(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), json={'hello': 'world'}) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 200 + data = yield from request.json() + assert data == {'hello': 'world'} + + +@asyncio.coroutine +def test_cloud_request_requiring_refresh_fail(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), status=403) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(False)) as mock_refresh: + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 403 + assert len(mock_refresh.mock_calls) == 1 + + +@asyncio.coroutine +def test_cloud_request_requiring_refresh_success(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), status=403) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(True)) as mock_refresh, \ + patch.object(cloud_api.Cloud, 'async_refresh_account_info', + return_value=mock_coro()) as mock_account_info: + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 403 + assert len(mock_refresh.mock_calls) == 1 + assert len(mock_account_info.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py new file mode 100644 index 00000000000..99e73461bc1 --- /dev/null +++ b/tests/components/cloud/test_http_api.py @@ -0,0 +1,157 @@ +"""Tests for the HTTP API for the cloud component.""" +import asyncio +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.cloud import DOMAIN, cloud_api + +from tests.common import mock_coro + + +@pytest.fixture +def cloud_client(hass, test_client): + """Fixture that can fetch from the cloud client.""" + hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { + 'cloud': { + 'mode': 'development' + } + })) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_account_view_no_account(cloud_client): + """Test fetching account if no account available.""" + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 400 + + +@asyncio.coroutine +def test_account_view(hass, cloud_client): + """Test fetching account if no account available.""" + cloud = MagicMock(account={'test': 'account'}) + hass.data[DOMAIN]['cloud'] = cloud + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 200 + result = yield from req.json() + assert result == {'test': 'account'} + + +@asyncio.coroutine +def test_login_view(hass, cloud_client): + """Test logging in.""" + cloud = MagicMock(account={'test': 'account'}) + cloud.async_refresh_account_info.return_value = mock_coro(None) + + with patch.object(cloud_api, 'async_login', + MagicMock(return_value=mock_coro(cloud))): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 200 + + result = yield from req.json() + assert result == {'test': 'account'} + assert hass.data[DOMAIN]['cloud'] is cloud + + +@asyncio.coroutine +def test_login_view_invalid_json(hass, cloud_client): + """Try logging in with invalid JSON.""" + req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') + assert req.status == 400 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_invalid_schema(hass, cloud_client): + """Try logging in with invalid schema.""" + req = yield from cloud_client.post('/api/cloud/login', json={ + 'invalid': 'schema' + }) + assert req.status == 400 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_request_timeout(hass, cloud_client): + """Test request timeout while trying to log in.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=asyncio.TimeoutError)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 502 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_invalid_credentials(hass, cloud_client): + """Test logging in with invalid credentials.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=cloud_api.Unauthenticated)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 401 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_unknown_error(hass, cloud_client): + """Test unknown error while logging in.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=cloud_api.UnknownError)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 500 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view(hass, cloud_client): + """Test logging out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.return_value = mock_coro(None) + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 200 + data = yield from req.json() + assert data == {'result': 'ok'} + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view_request_timeout(hass, cloud_client): + """Test timeout while logging out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.side_effect = asyncio.TimeoutError + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert 'cloud' in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view_unknown_error(hass, cloud_client): + """Test unknown error while loggin out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.side_effect = cloud_api.UnknownError + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert 'cloud' in hass.data[DOMAIN] diff --git a/tests/test_config.py b/tests/test_config.py index d1b9a052b72..1cb5e00bee9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,8 @@ from homeassistant.components.config.group import ( CONFIG_PATH as GROUP_CONFIG_PATH) from homeassistant.components.config.automation import ( CONFIG_PATH as AUTOMATIONS_CONFIG_PATH) +from homeassistant.components.config.script import ( + CONFIG_PATH as SCRIPTS_CONFIG_PATH) from homeassistant.components.config.customize import ( CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) @@ -33,6 +35,7 @@ YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) +SCRIPTS_PATH = os.path.join(CONFIG_DIR, SCRIPTS_CONFIG_PATH) CUSTOMIZE_PATH = os.path.join(CONFIG_DIR, CUSTOMIZE_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -68,6 +71,9 @@ class TestConfig(unittest.TestCase): if os.path.isfile(AUTOMATIONS_PATH): os.remove(AUTOMATIONS_PATH) + if os.path.isfile(SCRIPTS_PATH): + os.remove(SCRIPTS_PATH) + if os.path.isfile(CUSTOMIZE_PATH): os.remove(CUSTOMIZE_PATH) diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 0af5321c65f..ccd71e55d16 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -201,6 +201,7 @@ def mock_aiohttp_client(): with mock.patch('aiohttp.ClientSession') as mock_session: instance = mock_session() + instance.request = mocker.match_request for method in ('get', 'post', 'put', 'options', 'delete'): setattr(instance, method, From 7de73e9ef723a7cc3a47ac507632153fb9eb2962 Mon Sep 17 00:00:00 2001 From: Jeff McGehee Date: Tue, 29 Aug 2017 17:53:41 -0400 Subject: [PATCH 073/277] Bayesian Binary Sensor (#8810) * Bayesian Binary Sensor Why: * It would be beneficial to leverage various sensor outputs in a Bayesian manner in order to sense more complex events. This change addresses the need by: * `BayesianBinarySensor` class in `./homeassistant/components/binary_sensor/bayesian.py` * Tests in `./tests/components/binary_sensor/test_bayesian.py` Caveats: This is my first time in this code-base. I did try to follow conventions that I was able to find, but I'm sure there will be some issues to straighten out. * minor cleanup * Address reviewer's comments This change addresses the need by: * Removing `CONF_SENSOR_CLASS` and its usage in `get_deprecated`. * Make probability update function a static method, and use single `_` to match project conventions. * Address linter failures * fix `device_class` declaration * Address Comments Why: * Not validating config schema enough. * Not following common practices for async initialization. * Naive implementation of Bayes' rule. This change addresses the need by: * Improving config validation for observations. * Moving initialization logic into `async_added_to_hass`. * Re-configuring Bayesian updates to allow true P|Q usage. * address linting issues * Improve DRYness by adding `_update_current_obs` method * update doc strings and ensure functions are set up properly for async * Make only 1 state change handle * fix style * fix style part 2 * fix lint --- .../components/binary_sensor/bayesian.py | 211 ++++++++++++++++++ .../components/binary_sensor/test_bayesian.py | 176 +++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 homeassistant/components/binary_sensor/bayesian.py create mode 100644 tests/components/binary_sensor/test_bayesian.py diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py new file mode 100644 index 00000000000..4c62735a6f9 --- /dev/null +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -0,0 +1,211 @@ +""" +Use Bayesian Inference to trigger a binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bayesian/ +""" +import asyncio +import logging +from collections import OrderedDict + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + CONF_PLATFORM, CONF_STATE, STATE_UNKNOWN) +from homeassistant.core import callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +CONF_OBSERVATIONS = 'observations' +CONF_PRIOR = 'prior' +CONF_PROBABILITY_THRESHOLD = 'probability_threshold' +CONF_P_GIVEN_F = 'prob_given_false' +CONF_P_GIVEN_T = 'prob_given_true' +CONF_TO_STATE = 'to_state' + +DEFAULT_NAME = 'BayesianBinary' + +NUMERIC_STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: 'numeric_state', + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: CONF_STATE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TO_STATE): cv.string, + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): + cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Required(CONF_OBSERVATIONS): vol.Schema( + vol.All(cv.ensure_list, [vol.Any(NUMERIC_STATE_SCHEMA, + STATE_SCHEMA)]) + ), + vol.Required(CONF_PRIOR): vol.Coerce(float), + vol.Optional(CONF_PROBABILITY_THRESHOLD): + vol.Coerce(float), +}) + + +def update_probability(prior, prob_true, prob_false): + """Update probability using Bayes' rule.""" + numerator = prob_true * prior + denominator = numerator + prob_false * (1 - prior) + + probability = numerator / denominator + return probability + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Threshold sensor.""" + name = config.get(CONF_NAME) + observations = config.get(CONF_OBSERVATIONS) + prior = config.get(CONF_PRIOR) + probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD, 0.5) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_devices([ + BayesianBinarySensor(name, prior, observations, probability_threshold, + device_class) + ], True) + + +class BayesianBinarySensor(BinarySensorDevice): + """Representation of a Bayesian sensor.""" + + def __init__(self, name, prior, observations, probability_threshold, + device_class): + """Initialize the Bayesian sensor.""" + self._name = name + self._observations = observations + self._probability_threshold = probability_threshold + self._device_class = device_class + self._deviation = False + self.prior = prior + self.probability = prior + + self.current_obs = OrderedDict({}) + + self.entity_obs = {obs['entity_id']: obs for obs in self._observations} + + self.watchers = { + 'numeric_state': self._process_numeric_state, + 'state': self._process_state + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + @callback + # pylint: disable=invalid-name + def async_threshold_sensor_state_listener(entity, old_state, + new_state): + """Handle sensor state changes.""" + if new_state.state == STATE_UNKNOWN: + return + + entity_obs = self.entity_obs[entity] + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) + + prior = self.prior + print(self.current_obs.values()) + for obs in self.current_obs.values(): + prior = update_probability(prior, obs['prob_true'], + obs['prob_false']) + + self.probability = prior + + self.hass.async_add_job(self.async_update_ha_state, True) + + entities = [obs['entity_id'] for obs in self._observations] + async_track_state_change( + self.hass, entities, async_threshold_sensor_state_listener) + + def _update_current_obs(self, entity_observation, should_trigger): + """Update current observation.""" + entity = entity_observation['entity_id'] + + if should_trigger: + prob_true = entity_observation['prob_given_true'] + prob_false = entity_observation.get( + 'prob_given_false', 1 - prob_true) + + self.current_obs[entity] = { + 'prob_true': prob_true, + 'prob_false': prob_false + } + + else: + self.current_obs.pop(entity, None) + + def _process_numeric_state(self, entity_observation): + """Add entity to current_obs if numeric state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.async_numeric_state( + self.hass, entity, + entity_observation.get('below'), + entity_observation.get('above'), None, entity_observation) + + self._update_current_obs(entity_observation, should_trigger) + + def _process_state(self, entity_observation): + """Add entity to current observations if state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.state( + self.hass, entity, entity_observation.get('to_state')) + + self._update_current_obs(entity_observation, should_trigger) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._deviation + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + 'observations': [val for val in self.current_obs.values()], + 'probability': self.probability, + 'probability_threshold': self._probability_threshold + } + + @asyncio.coroutine + def async_update(self): + """Get the latest data and update the states.""" + self._deviation = bool(self.probability > self._probability_threshold) diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py new file mode 100644 index 00000000000..f86047f3a3d --- /dev/null +++ b/tests/components/binary_sensor/test_bayesian.py @@ -0,0 +1,176 @@ +"""The test for the bayesian sensor platform.""" +import unittest + +from homeassistant.setup import setup_component +from homeassistant.components.binary_sensor import bayesian + +from tests.common import get_test_home_assistant + + +class TestBayesianBinarySensor(unittest.TestCase): + """Test the threshold sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_sensor_numeric_state(self): + """Test sensor on numeric state platform observations.""" + config = { + 'binary_sensor': { + 'platform': + 'bayesian', + 'name': + 'Test_Binary', + 'observations': [{ + 'platform': 'numeric_state', + 'entity_id': 'sensor.test_monitored', + 'below': 10, + 'above': 5, + 'prob_given_true': 0.6 + }, { + 'platform': 'numeric_state', + 'entity_id': 'sensor.test_monitored1', + 'below': 7, + 'above': 5, + 'prob_given_true': 0.9, + 'prob_given_false': 0.1 + }], + 'prior': + 0.2, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 6) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 6) + self.hass.states.set('sensor.test_monitored1', 6) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_false': 0.4, + 'prob_true': 0.6 + }, { + 'prob_false': 0.1, + 'prob_true': 0.9 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.7714285714285715, + state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 6) + self.hass.states.set('sensor.test_monitored1', 0) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 15) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + + assert state.state == 'off' + + def test_sensor_state(self): + """Test sensor on state platform observations.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'off', + 'prob_given_true': 0.8, + 'prob_given_false': 0.4 + }], + 'prior': + 0.2, + 'probability_threshold': + 0.32, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'on') + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_true': 0.8, + 'prob_false': 0.4 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.33333333, state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + def test_probability_updates(self): + """Test probability update function.""" + prob_true = [0.3, 0.6, 0.8] + prob_false = [0.7, 0.4, 0.2] + prior = 0.5 + + for pt, pf in zip(prob_true, prob_false): + prior = bayesian.update_probability(prior, pt, pf) + + self.assertAlmostEqual(0.720000, prior) + + prob_true = [0.8, 0.3, 0.9] + prob_false = [0.6, 0.4, 0.2] + prior = 0.7 + + for pt, pf in zip(prob_true, prob_false): + prior = bayesian.update_probability(prior, pt, pf) + + self.assertAlmostEqual(0.9130434782608695, prior) From ebc7ade59198b2ac97cd3ff2c877bcef5d022a5f Mon Sep 17 00:00:00 2001 From: Nicholas Sielicki Date: Tue, 29 Aug 2017 17:08:56 -0500 Subject: [PATCH 074/277] directv: extended discovery via REST api, bug fix (#8800) * fix not providing device for discovered directvs This fixes a bug introduced at 6884965c80 Discovered directv boxes would not be instantiated with a DEVICE parameter. Signed-off-by: Nicholas Sielicki * directv: add discovery of RVU clients If discovery is used with directv, also try to further discover and configure RVU client set-top boxes by requesting information from a REST service running on the main directv box/RVU-server. This commit also disables discovery if any directv configuration is supplied by the user. Signed-off-by: Nicholas Sielicki * components/media_player/directv.py: use hass.data Use hass.data instead of a global to remember state. Signed-off-by: Nicholas Sielicki * unconditionally import requests in directv.py Requests is a core requirement, so we're okay to import at the top of the file rather than conditionally / in a function. Signed-off-by: Nicholas Sielicki --- .../components/media_player/directv.py | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 599b8fbbd71..a334dc7caa4 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.directv/ """ import voluptuous as vol +import requests from homeassistant.components.media_player import ( MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, @@ -25,7 +26,7 @@ SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY -KNOWN_HOSTS = [] +DATA_DIRECTV = "data_directv" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -37,32 +38,45 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DirecTV platform.""" + known_devices = hass.data.get(DATA_DIRECTV) + if not known_devices: + known_devices = [] hosts = [] - if discovery_info: - host = discovery_info.get('host') - - if host in KNOWN_HOSTS: - return - - hosts.append([ - 'DirecTV_' + discovery_info.get('serial', ''), - host, DEFAULT_PORT - ]) - - elif CONF_HOST in config: + if CONF_HOST in config: hosts.append([ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), config.get(CONF_DEVICE) ]) + elif discovery_info: + host = discovery_info.get('host') + name = 'DirecTV_' + discovery_info.get('serial', '') + + # attempt to discover additional RVU units + try: + resp = requests.get( + 'http://%s:%d/info/getLocations' % (host, DEFAULT_PORT)).json() + if "locations" in resp: + for loc in resp["locations"]: + if("locationName" in loc and "clientAddr" in loc + and loc["clientAddr"] not in known_devices): + hosts.append([str.title(loc["locationName"]), host, + DEFAULT_PORT, loc["clientAddr"]]) + + except requests.exceptions.RequestException: + # bail out and just go forward with uPnP data + if DEFAULT_DEVICE not in known_devices: + hosts.append([name, host, DEFAULT_PORT, DEFAULT_DEVICE]) + dtvs = [] for host in hosts: dtvs.append(DirecTvDevice(*host)) - KNOWN_HOSTS.append(host) + known_devices.append(host[-1]) add_devices(dtvs) + hass.data[DATA_DIRECTV] = known_devices return True From 8673e539406336caaac834c8f277bb187124b62e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 30 Aug 2017 06:06:18 +0200 Subject: [PATCH 075/277] Upgrade pyasn1 to 0.3.3 and pyasn1-modules to 0.1.1 (#9216) --- homeassistant/components/notify/xmpp.py | 4 ++-- requirements_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index d04eb91b6c4..42c7a3953b9 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -15,8 +15,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.2', - 'pyasn1-modules==0.0.11'] + 'pyasn1==0.3.3', + 'pyasn1-modules==0.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b357f9ffc53..d0ab3cbae48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -545,10 +545,10 @@ pyalarmdotcom==0.3.0 pyarlo==0.0.4 # homeassistant.components.notify.xmpp -pyasn1-modules==0.0.11 +pyasn1-modules==0.1.1 # homeassistant.components.notify.xmpp -pyasn1==0.3.2 +pyasn1==0.3.3 # homeassistant.components.apple_tv pyatv==0.3.4 From 4aafcfa478418ae65efcaeb80a8d8ee1be4a331e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 30 Aug 2017 06:06:31 +0200 Subject: [PATCH 076/277] Upgrade sendgrid to 5.0.1 (#9215) --- homeassistant/components/notify/sendgrid.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index f67eae6c611..545cddfadea 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.0.0'] +REQUIREMENTS = ['sendgrid==5.0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d0ab3cbae48..374c903d81a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -878,7 +878,7 @@ schiene==0.18 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.0.0 +sendgrid==5.0.1 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat From f76436f3266badbb382a49bec22cd915572c5c09 Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Wed, 30 Aug 2017 04:01:01 -0400 Subject: [PATCH 077/277] Fix fitbit error when trying to access token after upgrade. (#9183) * - Fixes Fitbit error when trying to refresh oauth token The 3rd python-fitbit module requires an extra kwarg on the FitBit constructor called refresh_cb. The value should be a function that accepts one argument token. This value will be a dictionary with the keys: 'access_token', 'refresh_token', 'expires_at' This implements a lambda refresh_cb as required by the Fitbit module to work, however the new token will always be save manually on every update() call. * Simplified by calling expires_at instead reading again from dict --- homeassistant/components/sensor/fitbit.py | 37 ++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 605805c028d..5876a059672 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -260,13 +260,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): access_token = config_file.get(ATTR_ACCESS_TOKEN) refresh_token = config_file.get(ATTR_REFRESH_TOKEN) + expires_at = config_file.get(ATTR_LAST_SAVED_AT) if None not in (access_token, refresh_token): authd_client = fitbit.Fitbit(config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET), access_token=access_token, - refresh_token=refresh_token) + refresh_token=refresh_token, + expires_at=expires_at, + refresh_cb=lambda x: None) - if int(time.time()) - config_file.get(ATTR_LAST_SAVED_AT, 0) > 3600: + if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() authd_client.system = authd_client.user_profile_get()["user"]["locale"] @@ -338,12 +341,14 @@ class FitbitAuthCallbackView(HomeAssistantView): response_message = """Fitbit has been successfully authorized! You can close this window now!""" + result = None if data.get('code') is not None: redirect_uri = '{}{}'.format( hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) try: - self.oauth.fetch_access_token(data.get('code'), redirect_uri) + result = self.oauth.fetch_access_token(data.get('code'), + redirect_uri) except MissingTokenError as error: _LOGGER.error("Missing token: %s", error) response_message = """Something went wrong when @@ -361,15 +366,23 @@ class FitbitAuthCallbackView(HomeAssistantView): An unknown error occurred. Please try again! """ + if result is None: + _LOGGER.error("Unknown error when authing") + response_message = """Something went wrong when + attempting authenticating with Fitbit. + An unknown error occurred. Please try again! + """ + html_response = """Fitbit Auth

{}

""".format(response_message) - config_contents = { - ATTR_ACCESS_TOKEN: self.oauth.token['access_token'], - ATTR_REFRESH_TOKEN: self.oauth.token['refresh_token'], - ATTR_CLIENT_ID: self.oauth.client_id, - ATTR_CLIENT_SECRET: self.oauth.client_secret - } + if result: + config_contents = { + ATTR_ACCESS_TOKEN: result.get('access_token'), + ATTR_REFRESH_TOKEN: result.get('refresh_token'), + ATTR_CLIENT_ID: self.oauth.client_id, + ATTR_CLIENT_SECRET: self.oauth.client_secret + } if not config_from_file(hass.config.path(FITBIT_CONFIG_FILE), config_contents): _LOGGER.error("Failed to save config file") @@ -490,9 +503,11 @@ class FitbitSensor(Entity): if self.resource_type == 'activities/heart': self._state = response[container][-1]. \ get('value').get('restingHeartRate') + + token = self.client.client.session.token config_contents = { - ATTR_ACCESS_TOKEN: self.client.client.token['access_token'], - ATTR_REFRESH_TOKEN: self.client.client.token['refresh_token'], + ATTR_ACCESS_TOKEN: token.get('access_token'), + ATTR_REFRESH_TOKEN: token.get('refresh_token'), ATTR_CLIENT_ID: self.client.client.client_id, ATTR_CLIENT_SECRET: self.client.client.client_secret, ATTR_LAST_SAVED_AT: int(time.time()) From 56f9ccb877a386f97eb17220271d5397dc246787 Mon Sep 17 00:00:00 2001 From: Riccardo Canta Date: Wed, 30 Aug 2017 15:10:02 +0200 Subject: [PATCH 078/277] Allow sonos to select album as a source (#9221) Importing the fix in the PR https://github.com/home-assistant/home-assistant/pull/8258 I noticed that the same error is present also for Spotify album so I have extended the code and tested it. It works fine on my setup --- homeassistant/components/media_player/sonos.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 63d27299aa7..03be42d07ff 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -897,7 +897,8 @@ class SonosDevice(MediaPlayerDevice): src = fav.pop() self._source_name = src['title'] - if 'object.container.playlistContainer' in src['meta']: + if ('object.container.playlistContainer' in src['meta'] or + 'object.container.album.musicAlbum' in src['meta']): self._replace_queue_with_playlist(src) self._player.play_from_queue(0) else: From 3a0e38aa738072b9a153b54b49b5a21fba9eea1b Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Wed, 30 Aug 2017 17:13:36 +0200 Subject: [PATCH 079/277] Add max_age to statistics sensor (#8790) * Add max_age to statistics sensor * Allow only non-zero sampling sizes * Fix long line * Fix style --- homeassistant/components/sensor/statistics.py | 39 ++++++++++++++----- tests/components/sensor/test_statistics.py | 35 +++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 2d7b74e8791..34d3cabf26b 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -18,6 +18,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,8 @@ ATTR_SAMPLING_SIZE = 'sampling_size' ATTR_TOTAL = 'total' CONF_SAMPLING_SIZE = 'sampling_size' +CONF_MAX_AGE = 'max_age' + DEFAULT_NAME = 'Stats' DEFAULT_SIZE = 20 ICON = 'mdi:calculator' @@ -41,7 +44,9 @@ ICON = 'mdi:calculator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): cv.positive_int, + vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_MAX_AGE): cv.time_period }) @@ -51,16 +56,18 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) sampling_size = config.get(CONF_SAMPLING_SIZE) + max_age = config.get(CONF_MAX_AGE, None) async_add_devices( - [StatisticsSensor(hass, entity_id, name, sampling_size)], True) + [StatisticsSensor(hass, entity_id, name, sampling_size, max_age)], + True) return True class StatisticsSensor(Entity): """Representation of a Statistics sensor.""" - def __init__(self, hass, entity_id, name, sampling_size): + def __init__(self, hass, entity_id, name, sampling_size, max_age): """Initialize the Statistics sensor.""" self._hass = hass self._entity_id = entity_id @@ -71,11 +78,12 @@ class StatisticsSensor(Entity): else: self._name = '{} {}'.format(name, ATTR_COUNT) self._sampling_size = sampling_size + self._max_age = max_age self._unit_of_measurement = None - if self._sampling_size == 0: - self.states = deque() - else: - self.states = deque(maxlen=self._sampling_size) + self.states = deque(maxlen=self._sampling_size) + if self._max_age is not None: + self.ages = deque(maxlen=self._sampling_size) + self.median = self.mean = self.variance = self.stdev = 0 self.min = self.max = self.total = self.count = 0 self.average_change = self.change = 0 @@ -89,6 +97,9 @@ class StatisticsSensor(Entity): try: self.states.append(float(new_state.state)) + if self._max_age is not None: + now = dt_util.utcnow() + self.ages.append(now) self.count = self.count + 1 except ValueError: self.count = self.count + 1 @@ -128,8 +139,7 @@ class StatisticsSensor(Entity): ATTR_MAX_VALUE: self.max, ATTR_MEDIAN: self.median, ATTR_MIN_VALUE: self.min, - ATTR_SAMPLING_SIZE: 'unlimited' if self._sampling_size is - 0 else self._sampling_size, + ATTR_SAMPLING_SIZE: self._sampling_size, ATTR_STANDARD_DEVIATION: self.stdev, ATTR_TOTAL: self.total, ATTR_VARIANCE: self.variance, @@ -142,9 +152,20 @@ class StatisticsSensor(Entity): """Return the icon to use in the frontend, if any.""" return ICON + def _purge_old(self): + """Remove states which are older than self._max_age.""" + now = dt_util.utcnow() + + while (len(self.ages) > 0) and (now - self.ages[0]) > self._max_age: + self.ages.popleft() + self.states.popleft() + @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" + if self._max_age is not None: + self._purge_old() + if not self.is_binary: try: self.mean = round(statistics.mean(self.states), 2) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 753b18f137f..ba71c6e3993 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -4,7 +4,10 @@ import statistics from homeassistant.setup import setup_component from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.util import dt as dt_util from tests.common import get_test_home_assistant +from unittest.mock import patch +from datetime import datetime, timedelta class TestStatisticsSensor(unittest.TestCase): @@ -100,3 +103,35 @@ class TestStatisticsSensor(unittest.TestCase): self.assertEqual(3.8, state.attributes.get('min_value')) self.assertEqual(14, state.attributes.get('max_value')) + + def test_max_age(self): + """Test value deprecation.""" + mock_data = { + 'return_time': datetime(2017, 8, 2, 12, 23, tzinfo=dt_util.UTC), + } + + def mock_now(): + return mock_data['return_time'] + + with patch('homeassistant.components.sensor.statistics.dt_util.utcnow', + new=mock_now): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'max_age': {'minutes': 3} + } + }) + + for value in self.values: + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + # insert the next value one minute later + mock_data['return_time'] += timedelta(minutes=1) + + state = self.hass.states.get('sensor.test_mean') + + self.assertEqual(6, state.attributes.get('min_value')) + self.assertEqual(14, state.attributes.get('max_value')) From f2551c08af5d1d1b8e51d31987fcf0d2978eb13d Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Wed, 30 Aug 2017 20:11:45 +0200 Subject: [PATCH 080/277] Egardia package to .19 and change in port number for egardiaserver (#9225) --- homeassistant/components/alarm_control_panel/egardia.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index fe7db95651b..1ef5e5d64d8 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.18'] +REQUIREMENTS = ['pythonegardia==1.0.19'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ CONF_REPORT_SERVER_PORT = 'report_server_port' DEFAULT_NAME = 'Egardia' DEFAULT_PORT = 80 DEFAULT_REPORT_SERVER_ENABLED = False -DEFAULT_REPORT_SERVER_PORT = 85 +DEFAULT_REPORT_SERVER_PORT = 52010 DOMAIN = 'egardia' NOTIFICATION_ID = 'egardia_notification' diff --git a/requirements_all.txt b/requirements_all.txt index 374c903d81a..363bc1b3111 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -797,7 +797,7 @@ python-wink==1.5.1 python_openzwave==0.4.0.31 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.18 +pythonegardia==1.0.19 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From 214c92d787e11cf9ef86ede5b2fe099f3eab5586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 30 Aug 2017 21:42:27 +0200 Subject: [PATCH 081/277] pushbullet, send a file from url (#9189) * pushbullet, send a file from url * pushbullet, send a file from url * Simplify --- homeassistant/components/notify/pushbullet.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index e52348c3446..69e2cc4298a 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.pushbullet/ """ import logging +import mimetypes import voluptuous as vol @@ -20,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_URL = 'url' ATTR_FILE = 'file' +ATTR_FILE_URL = 'file_url' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -80,16 +82,11 @@ class PushBulletNotificationService(BaseNotificationService): targets = kwargs.get(ATTR_TARGET) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) - url = None - filepath = None - if data: - url = data.get(ATTR_URL, None) - filepath = data.get(ATTR_FILE, None) refreshed = False if not targets: # Backward compatibility, notify all devices in own account - self._push_data(filepath, message, title, url, self.pushbullet) + self._push_data(message, title, data, self.pushbullet) _LOGGER.info("Sent notification to self") return @@ -104,8 +101,7 @@ class PushBulletNotificationService(BaseNotificationService): # Target is email, send directly, don't use a target object # This also seems works to send to all devices in own account if ttype == 'email': - self._push_data(filepath, message, title, url, - self.pushbullet, tname) + self._push_data(message, title, data, self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) continue @@ -124,16 +120,19 @@ class PushBulletNotificationService(BaseNotificationService): # Attempt push_note on a dict value. Keys are types & target # name. Dict pbtargets has all *actual* targets. try: - self._push_data(filepath, message, title, url, + self._push_data(message, title, data, self.pbtargets[ttype][tname]) _LOGGER.info("Sent notification to %s/%s", ttype, tname) except KeyError: _LOGGER.error("No such target: %s/%s", ttype, tname) continue - def _push_data(self, filepath, message, title, url, pusher, tname=None): + def _push_data(self, title, message, data, pusher, tname=None): from pushbullet import PushError from pushbullet import Device + url = data.get(ATTR_URL) + filepath = data.get(ATTR_FILE) + file_url = data.get(ATTR_FILE_URL) try: if url: if isinstance(pusher, Device): @@ -144,9 +143,16 @@ class PushBulletNotificationService(BaseNotificationService): with open(filepath, "rb") as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) if filedata.get('file_type') == 'application/x-empty': - _LOGGER.error("Failed to send an empty file.") + _LOGGER.error("Can not send an empty file.") return pusher.push_file(title=title, body=message, **filedata) + elif file_url: + if not file_url.startswith('http'): + _LOGGER.error("Url should start with http or https.") + return + pusher.push_file(title=title, body=message, file_name=file_url, + file_url=file_url, + file_type=mimetypes.guess_type(file_url)[0]) else: if isinstance(pusher, Device): pusher.push_note(title, message) From 76c7eef7d8fadee84f32ed4f6752f56d443a5fb1 Mon Sep 17 00:00:00 2001 From: Kris Molendyke Date: Wed, 30 Aug 2017 16:21:54 -0400 Subject: [PATCH 082/277] Add Tank Utility sensor (#9132) * Add Tank Utility sensor * Fix, disable Pylint errors * Move coverage omission to single platform section * Do not catch unknown exceptions * Check for invalid credentials in setup * Update tank_utility.py --- .coveragerc | 1 + .../components/sensor/tank_utility.py | 138 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 142 insertions(+) create mode 100644 homeassistant/components/sensor/tank_utility.py diff --git a/.coveragerc b/.coveragerc index 93a422d6d5b..37d4fd831dc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -520,6 +520,7 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/synologydsm.py homeassistant/components/sensor/systemmonitor.py + homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py diff --git a/homeassistant/components/sensor/tank_utility.py b/homeassistant/components/sensor/tank_utility.py new file mode 100644 index 00000000000..01ace415159 --- /dev/null +++ b/homeassistant/components/sensor/tank_utility.py @@ -0,0 +1,138 @@ +""" +Support for the Tank Utility propane monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tank_utility/ +""" + +import datetime +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, + STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity + + +REQUIREMENTS = [ + "tank_utility==1.4.0" +] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, vol.Length(min=1)) +}) + +SENSOR_TYPE = "tank" +SENSOR_ROUNDING_PRECISION = 1 +SENSOR_UNIT_OF_MEASUREMENT = "%" +SENSOR_ATTRS = [ + "name", + "address", + "capacity", + "fuelType", + "orientation", + "status", + "time", + "time_iso" +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tank Utility sensor.""" + from tank_utility import auth + email = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + devices = config.get(CONF_DEVICES) + + try: + token = auth.get_token(email, password) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.error("Invalid credentials") + return + + all_sensors = [] + for device in devices: + sensor = TankUtilitySensor(email, password, token, device) + all_sensors.append(sensor) + add_devices(all_sensors, True) + + +class TankUtilitySensor(Entity): + """Representation of a Tank Utility sensor.""" + + def __init__(self, email, password, token, device): + """Initialize the sensor.""" + self._email = email + self._password = password + self._token = token + self._device = device + self._state = STATE_UNKNOWN + self._name = "Tank Utility " + self.device + self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT + self._attributes = {} + + @property + def device(self): + """Return the device identifier.""" + return self._device + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the device.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the attributes of the device.""" + return self._attributes + + def get_data(self): + """Get data from the device. + + Flatten dictionary to map device to map of device data. + + """ + from tank_utility import auth, device + data = {} + try: + data = device.get_device_data(self._token, self.device) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.info("Getting new token") + self._token = auth.get_token(self._email, self._password, + force=True) + data = device.get_device_data(self._token, self.device) + else: + raise http_error + data.update(data.pop("device", {})) + data.update(data.pop("lastReading", {})) + return data + + def update(self): + """Set the device state and attributes.""" + data = self.get_data() + self._state = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) + self._attributes = {k: v for k, v in data.items() if k in SENSOR_ATTRS} diff --git a/requirements_all.txt b/requirements_all.txt index 363bc1b3111..d9444d511a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -930,6 +930,9 @@ steamodd==4.21 # homeassistant.components.camera.onvif suds-py3==1.3.3.0 +# homeassistant.components.sensor.tank_utility +tank_utility==1.4.0 + # homeassistant.components.binary_sensor.tapsaff tapsaff==0.1.3 From 10e8aea46b107b7904fd0c6e7892189be2abeb4c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 30 Aug 2017 22:23:28 +0200 Subject: [PATCH 083/277] Upgrade shodan to 1.7.5 (#9228) --- homeassistant/components/sensor/shodan.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index c95d975ec47..3d86d940f4d 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.4'] +REQUIREMENTS = ['shodan==1.7.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d9444d511a4..a2bc1260aaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -888,7 +888,7 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.4 +shodan==1.7.5 # homeassistant.components.alarm_control_panel.simplisafe simplisafe-python==1.0.5 From 5f445b4a13ca5284d525acc977589d299eb0ce9c Mon Sep 17 00:00:00 2001 From: Sergey Isachenko Date: Thu, 31 Aug 2017 07:13:02 +0300 Subject: [PATCH 084/277] Tesla platform (#9211) * Tesla support implemetation * requirements_all.txt fix * .coveragerc fix * logging-too-many-args fix * logging-too-many-args attempt 2 * Post-review fixes. * requirements version fix * requirements * Lint fix * Hot fix * requirements_all.txt fix * Review preparation. * 1. Linting fix. 2. Minimal value for SCAN_INTERVAL hardcoded to 300 sec (to prevent possible ban form Tesla) * Removed redundant whitespace. * Fixed components according to @MartinHjelmare proposals and remarks. * .coveragerc as @MartinHjelmare suggested. * Minor changes * Fix docstrings * Update ordering * Update quotes * Minor changes * Update quotes --- .coveragerc | 3 + .../components/binary_sensor/tesla.py | 57 +++++++++++ homeassistant/components/climate/tesla.py | 93 ++++++++++++++++++ .../components/device_tracker/tesla.py | 57 +++++++++++ homeassistant/components/lock/tesla.py | 57 +++++++++++ homeassistant/components/sensor/tesla.py | 82 ++++++++++++++++ homeassistant/components/tesla.py | 95 +++++++++++++++++++ requirements_all.txt | 3 + 8 files changed, 447 insertions(+) create mode 100644 homeassistant/components/binary_sensor/tesla.py create mode 100644 homeassistant/components/climate/tesla.py create mode 100644 homeassistant/components/device_tracker/tesla.py create mode 100644 homeassistant/components/lock/tesla.py create mode 100644 homeassistant/components/sensor/tesla.py create mode 100644 homeassistant/components/tesla.py diff --git a/.coveragerc b/.coveragerc index 37d4fd831dc..b43688aa281 100644 --- a/.coveragerc +++ b/.coveragerc @@ -170,6 +170,9 @@ omit = homeassistant/components/tellstick.py homeassistant/components/*/tellstick.py + homeassistant/components/tesla.py + homeassistant/components/*/tesla.py + homeassistant/components/*/thinkingcleaner.py homeassistant/components/tradfri.py diff --git a/homeassistant/components/binary_sensor/tesla.py b/homeassistant/components/binary_sensor/tesla.py new file mode 100644 index 00000000000..af7e394b50e --- /dev/null +++ b/homeassistant/components/binary_sensor/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tesla/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla binary sensor.""" + devices = [ + TeslaBinarySensor( + device, hass.data[TESLA_DOMAIN]['controller'], 'connectivity') + for device in hass.data[TESLA_DOMAIN]['devices']['binary_sensor']] + add_devices(devices, True) + + +class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): + """Implement an Tesla binary sensor for parking and charger.""" + + def __init__(self, tesla_device, controller, sensor_type): + """Initialisation of binary sensor.""" + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self._state = False + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._sensor_type = sensor_type + + @property + def device_class(self): + """Return the class of this binary sensor.""" + return self._sensor_type + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + self._state = self.tesla_device.get_value() diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py new file mode 100644 index 00000000000..39d002e72d9 --- /dev/null +++ b/homeassistant/components/climate/tesla.py @@ -0,0 +1,93 @@ +""" +Support for Tesla HVAC system. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.tesla/ +""" +import logging + +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.climate import ClimateDevice, ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import ( + TEMP_FAHRENHEIT, TEMP_CELSIUS, ATTR_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +OPERATION_LIST = [STATE_ON, STATE_OFF] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla climate platform.""" + devices = [TeslaThermostat(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['climate']] + add_devices(devices, True) + + +class TeslaThermostat(TeslaDevice, ClimateDevice): + """Representation of a Tesla climate.""" + + def __init__(self, tesla_device, controller): + """Initialize the Tesla device.""" + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._target_temperature = None + self._temperature = None + self._name = self.tesla_device.name + + @property + def current_operation(self): + """Return current operation ie. On or Off.""" + mode = self.tesla_device.is_hvac_enabled() + if mode: + return OPERATION_LIST[0] # On + else: + return OPERATION_LIST[1] # Off + + @property + def operation_list(self): + """List of available operation modes.""" + return OPERATION_LIST + + def update(self): + """Called by the Tesla device callback to update state.""" + _LOGGER.debug("Updating: %s", self._name) + self.tesla_device.update() + self._target_temperature = self.tesla_device.get_goal_temp() + self._temperature = self.tesla_device.get_current_temp() + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + _LOGGER.debug("Setting temperature for: %s", self._name) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature: + self.tesla_device.set_temperature(temperature) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, cool, heat, off).""" + _LOGGER.debug("Setting mode for: %s", self._name) + if operation_mode == OPERATION_LIST[1]: # off + self.tesla_device.set_status(False) + elif operation_mode == OPERATION_LIST[0]: # heat + self.tesla_device.set_status(True) diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py new file mode 100644 index 00000000000..4945e98a94d --- /dev/null +++ b/homeassistant/components/device_tracker/tesla.py @@ -0,0 +1,57 @@ +""" +Support for the Tesla platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tesla/ +""" +import logging + +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the Tesla tracker.""" + TeslaDeviceTracker( + hass, config, see, + hass.data[TESLA_DOMAIN]['devices']['devices_tracker']) + return True + + +class TeslaDeviceTracker(object): + """A class representing a Tesla device.""" + + def __init__(self, hass, config, see, tesla_devices): + """Initialize the Tesla device scanner.""" + self.hass = hass + self.see = see + self.devices = tesla_devices + self._update_info() + + track_utc_time_change( + self.hass, self._update_info, second=range(0, 60, 30)) + + def _update_info(self, now=None): + """Update the device info.""" + for device in self.devices: + device.update() + name = device.name + _LOGGER.debug("Updating device position: %s", name) + dev_id = slugify(device.uniq_name) + location = device.get_location() + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/lock/tesla.py b/homeassistant/components/lock/tesla.py new file mode 100644 index 00000000000..3e93e4787a0 --- /dev/null +++ b/homeassistant/components/lock/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla door locks. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.tesla/ +""" +import logging + +from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla lock platform.""" + devices = [TeslaLock(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['lock']] + add_devices(devices, True) + + +class TeslaLock(TeslaDevice, LockDevice): + """Representation of a Tesla door lock.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the lock.""" + self._state = None + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + def lock(self, **kwargs): + """Send the lock command.""" + _LOGGER.debug("Locking doors for: %s", self._name) + self.tesla_device.lock() + self._state = STATE_LOCKED + + def unlock(self, **kwargs): + """Send the unlock command.""" + _LOGGER.debug("Unlocking doors for: %s", self._name) + self.tesla_device.unlock() + self._state = STATE_UNLOCKED + + @property + def is_locked(self): + """Get whether the lock is in locked state.""" + return self._state == STATE_LOCKED + + def update(self): + """Updating state of the lock.""" + _LOGGER.debug("Updating state for: %s", self._name) + self.tesla_device.update() + self._state = STATE_LOCKED if self.tesla_device.is_locked() \ + else STATE_UNLOCKED diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py new file mode 100644 index 00000000000..fc31a5543e2 --- /dev/null +++ b/homeassistant/components/sensor/tesla.py @@ -0,0 +1,82 @@ +""" +Sensors for the Tesla sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tesla/ +""" +import logging +from datetime import timedelta + +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +SCAN_INTERVAL = timedelta(minutes=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla sensor platform.""" + controller = hass.data[TESLA_DOMAIN]['devices']['controller'] + devices = [] + + for device in hass.data[TESLA_DOMAIN]['devices']['sensor']: + if device.bin_type == 0x4: + devices.append(TeslaSensor(device, controller, 'inside')) + devices.append(TeslaSensor(device, controller, 'outside')) + else: + devices.append(TeslaSensor(device, controller)) + add_devices(devices, True) + + +class TeslaSensor(TeslaDevice, Entity): + """Representation of Tesla sensors.""" + + def __init__(self, tesla_device, controller, sensor_type=None): + """Initialisation of the sensor.""" + self.current_value = None + self._temperature_units = None + self.last_changed_time = None + self.type = sensor_type + super().__init__(tesla_device, controller) + + if self.type: + self._name = '{} ({})'.format(self.tesla_device.name, self.type) + self.entity_id = ENTITY_ID_FORMAT.format( + '{}_{}'.format(self.tesla_id, self.type)) + else: + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return self._temperature_units + + def update(self): + """Update the state from the sensor.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + if self.tesla_device.bin_type == 0x4: + if self.type == 'outside': + self.current_value = self.tesla_device.get_outside_temp() + else: + self.current_value = self.tesla_device.get_inside_temp() + + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + self._temperature_units = TEMP_FAHRENHEIT + else: + self._temperature_units = TEMP_CELSIUS + else: + self.current_value = self.tesla_device.battery_level() diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py new file mode 100644 index 00000000000..e48d805abab --- /dev/null +++ b/homeassistant/components/tesla.py @@ -0,0 +1,95 @@ +""" +Support for Tesla cars. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tesla/ +""" +from collections import defaultdict + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +REQUIREMENTS = ['teslajsonpy==0.0.11'] + +DOMAIN = 'tesla' + +TESLA_ID_FORMAT = '{}_{}' +TESLA_ID_LIST_SCHEMA = vol.Schema([int]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=300): + vol.All(cv.positive_int, vol.Clamp(min=300)), + }), +}, extra=vol.ALLOW_EXTRA) + +TESLA_COMPONENTS = [ + 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' +] + + +def setup(hass, base_config): + """Set up of Tesla platform.""" + from teslajsonpy.controller import Controller as teslaApi + + config = base_config.get(DOMAIN) + + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + update_interval = config.get(CONF_SCAN_INTERVAL) + if hass.data.get(DOMAIN) is None: + hass.data[DOMAIN] = { + 'controller': teslaApi(email, password, update_interval), + 'devices': defaultdict(list) + } + + all_devices = hass.data[DOMAIN]['controller'].list_vehicles() + + if not all_devices: + return False + + for device in all_devices: + hass.data[DOMAIN]['devices'][device.hass_type].append(device) + + for component in TESLA_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, base_config) + + return True + + +class TeslaDevice(Entity): + """Representation of a Tesla device.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the Tesla device.""" + self.tesla_device = tesla_device + self.controller = controller + self._name = self.tesla_device.name + self.tesla_id = slugify(self.tesla_device.uniq_name) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Get polling requirement from tesla device.""" + return self.tesla_device.should_poll + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + + if self.tesla_device.has_battery(): + attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() + return attr diff --git a/requirements_all.txt b/requirements_all.txt index a2bc1260aaa..01b38661af7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -946,6 +946,9 @@ tellduslive==0.3.4 # homeassistant.components.sensor.temper temperusb==1.5.3 +# homeassistant.components.tesla +teslajsonpy==0.0.11 + # homeassistant.components.thingspeak thingspeak==0.4.1 From de4a4fe71a3d416b608e887138e91055e14dfdc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Soko=C5=82owski?= Date: Thu, 31 Aug 2017 06:19:06 +0200 Subject: [PATCH 085/277] [light.tradfri] Full range of white spectrum lightbulbs support (#9224) * [light.tradfri] Support for pytradfri version supporting full white spectrum * [light.tradfri] Checkout pytradfri master * Developer docker image adjusted * [light.tradfri] pytradfri 2.2 support for white spectrum bulbs * Removed fix already included in dev * Style adjusted * pylint false positive overriden * Review remarks applied (#1) * make pylint happy * Review remarks --- homeassistant/components/light/tradfri.py | 99 ++++++++++++----------- homeassistant/components/tradfri.py | 15 ++-- requirements_all.txt | 2 +- 3 files changed, 63 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index b04640d7a8a..fa21af996cb 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -9,9 +9,10 @@ import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) -from homeassistant.components.light import \ - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA -from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS +from homeassistant.components.light import ( + PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA) +from homeassistant.components.tradfri import ( + KEY_GATEWAY, KEY_TRADFRI_GROUPS, KEY_API) from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) @@ -19,9 +20,7 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['tradfri'] PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA IKEA = 'IKEA of Sweden' -ALLOWED_TEMPERATURES = { - IKEA: {2200: 'efd275', 2700: 'f1e0b5', 4000: 'f5faf6'} -} +ALLOWED_TEMPERATURES = {IKEA} def setup_platform(hass, config, add_devices, discovery_info=None): @@ -30,24 +29,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return gateway_id = discovery_info['gateway'] + api = hass.data[KEY_API][gateway_id] gateway = hass.data[KEY_GATEWAY][gateway_id] - devices = gateway.get_devices() - lights = [dev for dev in devices if dev.has_light_control] - add_devices(Tradfri(light) for light in lights) + devices = api(gateway.get_devices()) + lights = [dev for dev in devices if api(dev).has_light_control] + add_devices(Tradfri(light, api) for light in lights) allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] if allow_tradfri_groups: - groups = gateway.get_groups() - add_devices(TradfriGroup(group) for group in groups) + groups = api(gateway.get_groups()) + add_devices(TradfriGroup(group, api) for group in groups) class TradfriGroup(Light): """The platform class required by hass.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Group.""" - self._group = light - self._name = light.name + self._group = api(light) + self._api = api + self._name = self._group.name @property def supported_features(self): @@ -71,20 +72,20 @@ class TradfriGroup(Light): def turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self._group.set_state(0) + self._api(self._group.set_state(0)) def turn_on(self, **kwargs): """Instruct the group lights to turn on, or dim.""" if ATTR_BRIGHTNESS in kwargs: - self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._group.set_state(1) + self._api(self._group.set_state(1)) def update(self): """Fetch new state data for this group.""" from pytradfri import RequestTimeout try: - self._group.update() + self._api(self._group.update()) except RequestTimeout: _LOGGER.warning("Tradfri update request timed out") @@ -92,14 +93,15 @@ class TradfriGroup(Light): class Tradfri(Light): """The platform class required by Home Asisstant.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Light.""" - self._light = light + self._light = api(light) + self._api = api # Caching of LightControl and light object - self._light_control = light.light_control - self._light_data = light.light_control.lights[0] - self._name = light.name + self._light_control = self._light.light_control + self._light_data = self._light_control.lights[0] + self._name = self._light.name self._rgb_color = None self._features = SUPPORT_BRIGHTNESS @@ -109,8 +111,20 @@ class Tradfri(Light): else: self._features |= SUPPORT_RGB_COLOR - self._ok_temps = ALLOWED_TEMPERATURES.get( - self._light.device_info.manufacturer) + self._ok_temps = \ + self._light.device_info.manufacturer in ALLOWED_TEMPERATURES + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + from pytradfri.color import MAX_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MAX_KELVIN_WS) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + from pytradfri.color import MIN_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MIN_KELVIN_WS) @property def supported_features(self): @@ -135,20 +149,13 @@ class Tradfri(Light): @property def color_temp(self): """Return the CT color value in mireds.""" - if (self._light_data.hex_color is None or + if (self._light_data.kelvin_color is None or self.supported_features & SUPPORT_COLOR_TEMP == 0 or not self._ok_temps): return None - - kelvin = next(( - kelvin for kelvin, hex_color in self._ok_temps.items() - if hex_color == self._light_data.hex_color), None) - if kelvin is None: - _LOGGER.error( - "Unexpected color temperature found for %s: %s", - self.name, self._light_data.hex_color) - return - return color_util.color_temperature_kelvin_to_mired(kelvin) + return color_util.color_temperature_kelvin_to_mired( + self._light_data.kelvin_color + ) @property def rgb_color(self): @@ -157,7 +164,7 @@ class Tradfri(Light): def turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._light_control.set_state(False) + self._api(self._light_control.set_state(False)) def turn_on(self, **kwargs): """ @@ -167,29 +174,27 @@ class Tradfri(Light): for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. """ if ATTR_BRIGHTNESS in kwargs: - self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._light_control.set_state(True) + self._api(self._light_control.set_state(True)) if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._light.light_control.set_hex_color( - color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR])) + self._api(self._light.light_control.set_hex_color( + color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]))) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and self._ok_temps: kelvin = color_util.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP]) - # find closest allowed kelvin temp from user input - kelvin = min(self._ok_temps.keys(), key=lambda x: abs(x - kelvin)) - self._light_control.set_hex_color(self._ok_temps[kelvin]) + self._api(self._light_control.set_kelvin_color(kelvin)) def update(self): """Fetch new state data for this light.""" from pytradfri import RequestTimeout try: - self._light.update() - except RequestTimeout: - _LOGGER.warning("Tradfri update request timed out") + self._api(self._light.update()) + except RequestTimeout as exception: + _LOGGER.warning("Tradfri update request timed out: %s", exception) # Handle Hue lights paired with the gateway # hex_color is 0 when bulb is unreachable diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index 31938cd15ff..34422819743 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -16,12 +16,13 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST, CONF_API_KEY from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -REQUIREMENTS = ['pytradfri==1.1'] +REQUIREMENTS = ['pytradfri==2.2'] DOMAIN = 'tradfri' CONFIG_FILE = 'tradfri.conf' KEY_CONFIG = 'tradfri_configuring' KEY_GATEWAY = 'tradfri_gateway' +KEY_API = 'tradfri_api' KEY_TRADFRI_GROUPS = 'tradfri_allow_tradfri_groups' CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups' DEFAULT_ALLOW_TRADFRI_GROUPS = True @@ -109,17 +110,21 @@ def async_setup(hass, config): @asyncio.coroutine def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): """Create a gateway.""" - from pytradfri import cli_api_factory, Gateway, RequestError, retry_timeout + from pytradfri import Gateway, RequestError + from pytradfri.api.libcoap_api import api_factory try: - api = retry_timeout(cli_api_factory(host, key)) + api = api_factory(host, key) except RequestError: return False - gateway = Gateway(api) - gateway_id = gateway.get_gateway_info().id + gateway = Gateway() + # pylint: disable=no-member + gateway_id = api(gateway.get_gateway_info()).id + hass.data.setdefault(KEY_API, {}) hass.data.setdefault(KEY_GATEWAY, {}) gateways = hass.data[KEY_GATEWAY] + hass.data[KEY_API][gateway_id] = api hass.data.setdefault(KEY_TRADFRI_GROUPS, {}) tradfri_groups = hass.data[KEY_TRADFRI_GROUPS] diff --git a/requirements_all.txt b/requirements_all.txt index 01b38661af7..c3722ec1307 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -803,7 +803,7 @@ pythonegardia==1.0.19 pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri==1.1 +pytradfri==2.2 # homeassistant.components.device_tracker.unifi pyunifi==2.13 From bb372940476903d157337e245914b6b0197968f0 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 31 Aug 2017 07:21:24 +0300 Subject: [PATCH 086/277] Allow panels with external URL (#9214) * Allow panels with external URL * Update comment --- homeassistant/components/frontend/__init__.py | 21 ++++++++++--------- tests/components/test_frontend.py | 19 ++++++++++++++++- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2f84abc745b..112c93403b0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -109,14 +109,13 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, component_name: name of the web component path: path to the HTML of the web component + (required unless url is provided) md5: the md5 hash of the web component (for versioning, optional) sidebar_title: title to show in the sidebar (optional) sidebar_icon: icon to show next to title in sidebar (optional) url_path: name to use in the url (defaults to component_name) - url: for the web component (for dev environment, optional) + url: for the web component (optional) config: config to be passed into the web component - - Warning: this API will probably change. Use at own risk. """ panels = hass.data.get(DATA_PANELS) if panels is None: @@ -127,14 +126,16 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, if url_path in panels: _LOGGER.warning("Overwriting component %s", url_path) - if not os.path.isfile(path): - _LOGGER.error( - "Panel %s component does not exist: %s", component_name, path) - return - if md5 is None: - with open(path) as fil: - md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() + if url is None: + if not os.path.isfile(path): + _LOGGER.error( + "Panel %s component does not exist: %s", component_name, path) + return + + if md5 is None: + with open(path) as fil: + md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() data = { 'url_path': url_path, diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index d99732fefd9..fdd33b99d2b 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -7,7 +7,7 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components.frontend import ( - DOMAIN, ATTR_THEMES, ATTR_EXTRA_HTML_URL) + DOMAIN, ATTR_THEMES, ATTR_EXTRA_HTML_URL, DATA_PANELS, register_panel) @pytest.fixture @@ -163,3 +163,20 @@ def test_extra_urls(mock_http_client_with_urls): assert resp.status == 200 text = yield from resp.text() assert text.find('href=\'https://domain.com/my_extra_url.html\'') >= 0 + + +@asyncio.coroutine +def test_panel_without_path(hass): + """Test panel registration without file path.""" + register_panel(hass, 'test_component', 'nonexistant_file') + assert hass.data[DATA_PANELS] == {} + + +@asyncio.coroutine +def test_panel_with_url(hass): + """Test panel registration without file path.""" + register_panel(hass, 'test_component', None, url='some_url') + assert hass.data[DATA_PANELS] == { + 'test_component': {'component_name': 'test_component', + 'url': 'some_url', + 'url_path': 'test_component'}} From e22ec28bce83f73c1fd5065cf782f3dc685ec1f8 Mon Sep 17 00:00:00 2001 From: "John K. Luebs" Date: Thu, 31 Aug 2017 01:18:01 -0400 Subject: [PATCH 087/277] Use ZCL mandatory attribute to determine ZHA light capabilities (#9232) The manadatory ColorCapabilities attribute should indicate whether a light is capable of XY color changes and/or color temperature. --- homeassistant/components/light/zha.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 2a3ce18d74e..e7ba394a977 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -27,8 +27,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): endpoint = discovery_info['endpoint'] try: - primaries = yield from endpoint.light_color['num_primaries'] - discovery_info['num_primaries'] = primaries + discovery_info['color_capabilities'] \ + = yield from endpoint.light_color['color_capabilities'] except (AttributeError, KeyError): pass @@ -54,11 +54,11 @@ class Light(zha.Entity, light.Light): self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 if zcl_clusters.lighting.Color.cluster_id in self._in_clusters: - # Not sure all color lights necessarily support this directly - # Should we emulate it? - self._supported_features |= light.SUPPORT_COLOR_TEMP - # Silly heuristic, not sure if it works widely - if kwargs.get('num_primaries', 1) >= 3: + color_capabilities = kwargs.get('color_capabilities', 0x10) + if color_capabilities & 0x10: + self._supported_features |= light.SUPPORT_COLOR_TEMP + + if color_capabilities & 0x08: self._supported_features |= light.SUPPORT_XY_COLOR self._supported_features |= light.SUPPORT_RGB_COLOR self._xy_color = (1.0, 1.0) From d816ff26adae5164597dbae91fcd124e6de25741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 31 Aug 2017 14:19:33 +0200 Subject: [PATCH 088/277] A bugfix for pushbullet (#9237) * Bug fix for pushbullet --- homeassistant/components/notify/pushbullet.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 69e2cc4298a..9b83184047f 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -129,17 +129,21 @@ class PushBulletNotificationService(BaseNotificationService): def _push_data(self, title, message, data, pusher, tname=None): from pushbullet import PushError - from pushbullet import Device + if data is None: + data = {} url = data.get(ATTR_URL) filepath = data.get(ATTR_FILE) file_url = data.get(ATTR_FILE_URL) try: if url: - if isinstance(pusher, Device): - pusher.push_link(title, url, body=message) - else: + if tname: pusher.push_link(title, url, body=message, email=tname) - elif filepath and self.hass.config.is_allowed_path(filepath): + else: + pusher.push_link(title, url, body=message) + elif filepath: + if not self.hass.config.is_allowed_path(filepath): + _LOGGER.error("Filepath is not valid or allowed.") + return with open(filepath, "rb") as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) if filedata.get('file_type') == 'application/x-empty': @@ -154,9 +158,9 @@ class PushBulletNotificationService(BaseNotificationService): file_url=file_url, file_type=mimetypes.guess_type(file_url)[0]) else: - if isinstance(pusher, Device): - pusher.push_note(title, message) - else: + if tname: pusher.push_note(title, message, email=tname) + else: + pusher.push_note(title, message) except PushError as err: _LOGGER.error("Notify failed: %s", err) From 99c1c9472a4448f12f7302bc1b290e4b47c5c1c3 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Thu, 31 Aug 2017 10:26:33 -0400 Subject: [PATCH 089/277] mopar sensor (#9136) * mopar sensor * fix doc url * mopar review comments * remove unneeded hass.data handling * fix lint --- .coveragerc | 1 + homeassistant/components/sensor/mopar.py | 165 +++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 169 insertions(+) create mode 100644 homeassistant/components/sensor/mopar.py diff --git a/.coveragerc b/.coveragerc index b43688aa281..5e27aed0182 100644 --- a/.coveragerc +++ b/.coveragerc @@ -486,6 +486,7 @@ omit = homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py homeassistant/components/sensor/modem_callerid.py + homeassistant/components/sensor/mopar.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/mvglive.py homeassistant/components/sensor/netdata.py diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py new file mode 100644 index 00000000000..0184cb2afdf --- /dev/null +++ b/homeassistant/components/sensor/mopar.py @@ -0,0 +1,165 @@ +""" +Sensor for Mopar vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mopar/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PIN, + ATTR_ATTRIBUTION, ATTR_COMMAND, + LENGTH_KILOMETERS) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['motorparts==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(days=7) +DOMAIN = 'mopar' +ATTR_VEHICLE_INDEX = 'vehicle_index' +SERVICE_REMOTE_COMMAND = 'remote_command' +COOKIE_FILE = 'mopar_cookies.pickle' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PIN): cv.positive_int +}) + +REMOTE_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_COMMAND): cv.string, + vol.Required(ATTR_VEHICLE_INDEX): cv.positive_int +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Mopar platform.""" + import motorparts + cookie = hass.config.path(COOKIE_FILE) + try: + session = motorparts.get_session(config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PIN), + cookie_path=cookie) + except motorparts.MoparError: + _LOGGER.error("failed to login") + return False + + def _handle_service(service): + """Handle service call.""" + index = service.data.get(ATTR_VEHICLE_INDEX) + command = service.data.get(ATTR_COMMAND) + try: + motorparts.remote_command(session, command, index) + except motorparts.MoparError as error: + _LOGGER.error(str(error)) + + hass.services.register(DOMAIN, SERVICE_REMOTE_COMMAND, _handle_service, + schema=REMOTE_COMMAND_SCHEMA) + + data = MoparData(session) + add_devices([MoparSensor(data, index) + for index, _ in enumerate(data.vehicles)], + True) + return True + + +# pylint: disable=too-few-public-methods +class MoparData(object): + """Container for Mopar vehicle data. + + Prevents session expiry re-login race condition. + """ + + def __init__(self, session): + """Initialize data.""" + self._session = session + self.vehicles = [] + self.vhrs = {} + self.tow_guides = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Update data.""" + import motorparts + _LOGGER.info("updating vehicle data") + try: + self.vehicles = motorparts.get_summary(self._session)['vehicles'] + except motorparts.MoparError: + _LOGGER.exception("failed to get summary") + return + for index, _ in enumerate(self.vehicles): + try: + self.vhrs[index] = motorparts.get_report(self._session, index) + self.tow_guides[index] = motorparts.get_tow_guide( + self._session, index) + except motorparts.MoparError: + _LOGGER.warning("failed to update for vehicle index %s", index) + + +class MoparSensor(Entity): + """Mopar vehicle sensor.""" + + def __init__(self, data, index): + """Initialize the sensor.""" + self._index = index + self._vehicle = {} + self._vhr = {} + self._tow_guide = {} + self._odometer = None + self._data = data + + def update(self): + """Update device state.""" + self._data.update() + self._vehicle = self._data.vehicles[self._index] + self._vhr = self._data.vhrs.get(self._index, {}) + self._tow_guide = self._data.tow_guides.get(self._index, {}) + if 'odometer' in self._vhr: + odo = float(self._vhr['odometer']) + self._odometer = int(self.hass.config.units.length( + odo, LENGTH_KILOMETERS)) + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {} {}'.format(self._vehicle['year'], + self._vehicle['make'], + self._vehicle['model']) + + @property + def state(self): + """Return the state of the sensor.""" + return self._odometer + + @property + def device_state_attributes(self): + """Return the state attributes.""" + import motorparts + attributes = { + ATTR_VEHICLE_INDEX: self._index, + ATTR_ATTRIBUTION: motorparts.ATTRIBUTION + } + attributes.update(self._vehicle) + attributes.update(self._vhr) + attributes.update(self._tow_guide) + return attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.hass.config.units.length_unit + + @property + def icon(self): + """Return the icon.""" + return 'mdi:car' diff --git a/requirements_all.txt b/requirements_all.txt index c3722ec1307..2df6562cf58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,6 +415,9 @@ miflora==0.1.16 # homeassistant.components.upnp miniupnpc==1.9 +# homeassistant.components.sensor.mopar +motorparts==1.0.0 + # homeassistant.components.tts mutagen==1.38 From 60342b47383a4a8fd1c5c198f763812e34d77315 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 31 Aug 2017 16:26:52 +0200 Subject: [PATCH 090/277] Upgrade discord.py to 0.16.11 (#9239) --- homeassistant/components/notify/discord.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index a4ce304167f..90212bca025 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['discord.py==0.16.10'] +REQUIREMENTS = ['discord.py==0.16.11'] CONF_TOKEN = 'token' diff --git a/requirements_all.txt b/requirements_all.txt index 2df6562cf58..823de83c610 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -172,7 +172,7 @@ denonavr==0.5.2 directpy==0.1 # homeassistant.components.notify.discord -discord.py==0.16.10 +discord.py==0.16.11 # homeassistant.components.updater distro==1.0.4 From 7d281fd22447e3d27fa45930ae355d8ae2b6e124 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Thu, 31 Aug 2017 10:29:18 -0400 Subject: [PATCH 091/277] Skip automatic events older than latest data (#9230) * Skip automatic events older than latest data * Update test --- .../components/device_tracker/automatic.py | 30 ++++++++++++++----- .../device_tracker/test_automatic.py | 3 ++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index a4495926f82..6ae038fd41c 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -205,6 +205,7 @@ class AutomaticData(object): self.hass = hass self.devices = devices self.vehicle_info = {} + self.vehicle_seen = {} self.client = client self.session = session self.async_see = async_see @@ -236,6 +237,14 @@ class AutomaticData(object): return yield from self.get_vehicle_info(vehicle) + if event.created_at < self.vehicle_seen[event.vehicle.id]: + # Skip events received out of order + _LOGGER.debug("Skipping out of order event. Event Created %s. " + "Last seen event: %s.", event.created_at, + self.vehicle_seen[event.vehicle.id]) + return + self.vehicle_seen[event.vehicle.id] = event.created_at + kwargs = self.vehicle_info[event.vehicle.id] if kwargs is None: # Ignored device @@ -323,15 +332,17 @@ class AutomaticData(object): if self.devices is not None and name not in self.devices: self.vehicle_info[vehicle.id] = None return - else: - self.vehicle_info[vehicle.id] = kwargs = { - ATTR_DEV_ID: vehicle.id, - ATTR_HOST_NAME: name, - ATTR_MAC: vehicle.id, - ATTR_ATTRIBUTES: { - ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, - } + + self.vehicle_info[vehicle.id] = kwargs = { + ATTR_DEV_ID: vehicle.id, + ATTR_HOST_NAME: name, + ATTR_MAC: vehicle.id, + ATTR_ATTRIBUTES: { + ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, } + } + self.vehicle_seen[vehicle.id] = \ + vehicle.updated_at or vehicle.created_at if vehicle.latest_location is not None: location = vehicle.latest_location @@ -352,4 +363,7 @@ class AutomaticData(object): kwargs[ATTR_GPS] = (location.lat, location.lon) kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m + if trips[0].ended_at >= self.vehicle_seen[vehicle.id]: + self.vehicle_seen[vehicle.id] = trips[0].ended_at + return kwargs diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index d572791168c..d40c1518ffa 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -1,5 +1,6 @@ """Test the automatic device tracker platform.""" import asyncio +from datetime import datetime import logging from unittest.mock import patch, MagicMock import aioautomatic @@ -71,10 +72,12 @@ def test_valid_credentials( vehicle.display_name = 'mock_display_name' vehicle.fuel_level_percent = 45.6 vehicle.latest_location = None + vehicle.updated_at = datetime(2017, 8, 13, 1, 2, 3) trip.end_location.lat = 45.567 trip.end_location.lon = 34.345 trip.end_location.accuracy_m = 5.6 + trip.ended_at = datetime(2017, 8, 13, 1, 2, 4) @asyncio.coroutine def get_session(*args, **kwargs): From acb6b7c68dfbc8599ce17d3dfcd483ef33481284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 31 Aug 2017 20:41:22 +0200 Subject: [PATCH 092/277] title and message was swapped in pushbullet (#9241) --- homeassistant/components/notify/pushbullet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 9b83184047f..d8b67413528 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -127,7 +127,7 @@ class PushBulletNotificationService(BaseNotificationService): _LOGGER.error("No such target: %s/%s", ttype, tname) continue - def _push_data(self, title, message, data, pusher, tname=None): + def _push_data(self, message, title, data, pusher, tname=None): from pushbullet import PushError if data is None: data = {} From 274e4449ea81abf56326d14f1b627090f15f495d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 31 Aug 2017 21:00:09 +0200 Subject: [PATCH 093/277] Fix possible KeyError (#9242) * Multiple devices per child per platform would lead to KeyError. --- homeassistant/components/mysensors.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 62fecddb8c4..c37116fb32d 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -485,12 +485,14 @@ def gw_callback_factory(hass): validated = validate_child(msg.gateway, msg.node_id, child) for platform, dev_ids in validated.items(): devices = get_mysensors_devices(hass, platform) - for idx, dev_id in enumerate(list(dev_ids)): + new_dev_ids = [] + for dev_id in dev_ids: if dev_id in devices: - dev_ids.pop(idx) signals.append(SIGNAL_CALLBACK.format(*dev_id)) - if dev_ids: - discover_mysensors_platform(hass, platform, dev_ids) + else: + new_dev_ids.append(dev_id) + if new_dev_ids: + discover_mysensors_platform(hass, platform, new_dev_ids) for signal in set(signals): # Only one signal per device is needed. # A device can have multiple platforms, ie multiple schemas. From 836b528bd3ca6f6ad5a8b13000b03a5f8f6a756f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 31 Aug 2017 21:16:44 +0200 Subject: [PATCH 094/277] WIP: Homematic improvments with new hass interfaces (#9058) * Remove hass to init hack and use official interfaces * fix lint * Fix lint * change style --- .../components/binary_sensor/homematic.py | 3 +- homeassistant/components/climate/homematic.py | 3 +- homeassistant/components/cover/homematic.py | 3 +- homeassistant/components/homematic.py | 57 ++++++++++--------- homeassistant/components/light/homematic.py | 3 +- homeassistant/components/sensor/homematic.py | 3 +- homeassistant/components/switch/homematic.py | 3 +- 7 files changed, 35 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index a82431a5ab8..2f464bc73cc 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -35,8 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMBinarySensor(hass, conf) - new_device.link_homematic() + new_device = HMBinarySensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index 60cda24eef9..ce6e9580e54 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -47,8 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMThermostat(hass, conf) - new_device.link_homematic() + new_device = HMThermostat(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index e8372b84ce4..9e3d675cabe 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMCover(hass, conf) - new_device.link_homematic() + new_device = HMCover(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index f9583d9be7a..dc5e641cbba 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -4,8 +4,8 @@ Support for HomeMatic devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ +import asyncio import os -import time import logging from datetime import timedelta from functools import partial @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['pyhomematic==0.1.30'] @@ -121,7 +121,6 @@ CONF_RESOLVENAMES_OPTIONS = [ ] DATA_HOMEMATIC = 'homematic' -DATA_DELAY = 'homematic_delay' DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' @@ -134,7 +133,6 @@ CONF_CALLBACK_PORT = 'callback_port' CONF_RESOLVENAMES = 'resolvenames' CONF_VARIABLES = 'variables' CONF_DEVICES = 'devices' -CONF_DELAY = 'delay' CONF_PRIMARY = 'primary' DEFAULT_LOCAL_IP = '0.0.0.0' @@ -145,7 +143,6 @@ DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' DEFAULT_VARIABLES = False DEFAULT_DEVICES = True -DEFAULT_DELAY = 0.5 DEFAULT_PRIMARY = False @@ -177,7 +174,6 @@ CONFIG_SCHEMA = vol.Schema({ }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): vol.Coerce(float), }), }, extra=vol.ALLOW_EXTRA) @@ -249,7 +245,6 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DELAY] = config[DOMAIN].get(CONF_DELAY) hass.data[DATA_DEVINIT] = {} hass.data[DATA_STORE] = set() @@ -277,7 +272,7 @@ def setup(hass, config): # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) - hass.data[DATA_HOMEMATIC] = HMConnection( + hass.data[DATA_HOMEMATIC] = homematic = HMConnection( local=config[DOMAIN].get(CONF_LOCAL_IP), localport=config[DOMAIN].get(CONF_LOCAL_PORT), remotes=remotes, @@ -286,7 +281,7 @@ def setup(hass, config): ) # Start server thread, connect to hosts, initialize to receive events - hass.data[DATA_HOMEMATIC].start() + homematic.start() # Stops server when HASS is shutting down hass.bus.listen_once( @@ -296,7 +291,7 @@ def setup(hass, config): entity_hubs = [] for _, hub_data in hosts.items(): entity_hubs.append(HMHub( - hass, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -359,7 +354,7 @@ def setup(hass, config): def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" - hass.data[DATA_HOMEMATIC].reconnect() + homematic.reconnect() hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, @@ -575,24 +570,27 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, hass, name, use_variables): + def __init__(self, homematic, name, use_variables): """Initialize HomeMatic hub.""" - self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) - self._homematic = hass.data[DATA_HOMEMATIC] + self._homematic = homematic self._variables = {} self._name = name self._state = STATE_UNKNOWN self._use_variables = use_variables + @asyncio.coroutine + def async_added_to_hass(self): + """Load data init callbacks.""" # Load data - track_time_interval(hass, self._update_hub, SCAN_INTERVAL_HUB) - self._update_hub(None) + async_track_time_interval( + self.hass, self._update_hub, SCAN_INTERVAL_HUB) + yield from self.hass.async_add_job(self._update_hub, None) if self._use_variables: - track_time_interval( - hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - self._update_variables(None) + async_track_time_interval( + self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) + yield from self.hass.async_add_job(self._update_variables, None) @property def name(self): @@ -624,7 +622,9 @@ class HMHub(Entity): """Retrieve latest state.""" state = self._homematic.getServiceMessages(self._name) self._state = STATE_UNKNOWN if state is None else len(state) - self.schedule_update_ha_state() + + if now: + self.schedule_update_ha_state() def _update_variables(self, now): """Retrive all variable data and update hmvariable states.""" @@ -640,7 +640,7 @@ class HMHub(Entity): state_change = True self._variables.update({key: value}) - if state_change: + if state_change and now: self.schedule_update_ha_state() def hm_set_variable(self, name, value): @@ -662,16 +662,15 @@ class HMHub(Entity): class HMDevice(Entity): """The HomeMatic device base object.""" - def __init__(self, hass, config): + def __init__(self, config): """Initialize a generic HomeMatic device.""" - self.hass = hass - self._homematic = hass.data[DATA_HOMEMATIC] self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) self._proxy = config.get(ATTR_PROXY) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} + self._homematic = None self._hmdevice = None self._connected = False self._available = False @@ -680,6 +679,11 @@ class HMDevice(Entity): if self._state: self._state = self._state.upper() + @asyncio.coroutine + def async_added_to_hass(self): + """Load data init callbacks.""" + yield from self.hass.async_add_job(self.link_homematic) + @property def should_poll(self): """Return false. HomeMatic states are pushed by the XML-RPC Server.""" @@ -728,16 +732,13 @@ class HMDevice(Entity): return True # Initialize + self._homematic = self.hass.data[DATA_HOMEMATIC] self._hmdevice = self._homematic.devices[self._proxy][self._address] self._connected = True try: # Initialize datapoints of this object self._init_data() - if self.hass.data[DATA_DELAY]: - # We optionally delay / pause loading of data to avoid - # overloading of CCU / Homegear - time.sleep(self.hass.data[DATA_DELAY]) self._load_data_from_hm() # Link events from pyhomematic diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 60865dd223e..807c19fffdb 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -24,8 +24,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMLight(hass, conf) - new_device.link_homematic() + new_device = HMLight(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 771b4a94bd4..061fd27ca69 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -57,8 +57,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSensor(hass, conf) - new_device.link_homematic() + new_device = HMSensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/switch/homematic.py b/homeassistant/components/switch/homematic.py index 566eff99828..487947598bb 100644 --- a/homeassistant/components/switch/homematic.py +++ b/homeassistant/components/switch/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSwitch(hass, conf) - new_device.link_homematic() + new_device = HMSwitch(conf) devices.append(new_device) add_devices(devices) From 0af4f8903d3457d8fc528676f0a9221a09a886ba Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 1 Sep 2017 00:23:11 +0200 Subject: [PATCH 095/277] Add available to sonos (#9243) * Readd sonos available flag / fix polling state * cleanup --- homeassistant/components/media_player/sonos.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 03be42d07ff..a5ef91ecc87 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -322,6 +322,7 @@ class SonosDevice(MediaPlayerDevice): self._media_title = None self._media_radio_show = None self._media_next_title = None + self._available = True self._support_previous_track = False self._support_next_track = False self._support_play = False @@ -386,6 +387,11 @@ class SonosDevice(MediaPlayerDevice): """Return coordinator of this player.""" return self._coordinator + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + def _is_available(self): try: sock = socket.create_connection( @@ -416,11 +422,11 @@ class SonosDevice(MediaPlayerDevice): self._player.get_sonos_favorites()['favorites'] if self._last_avtransport_event: - is_available = True + self._available = True else: - is_available = self._is_available() + self._available = self._is_available() - if not is_available: + if not self._available: self._player_volume = None self._player_volume_muted = None self._status = 'OFF' From a55895b66248afa3d9ee2e496279a061b31b2125 Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Fri, 1 Sep 2017 03:14:16 -0400 Subject: [PATCH 096/277] Make sure Ring binary_sensor state will update only if device_id matches (#9247) --- homeassistant/components/binary_sensor/ring.py | 5 +++-- tests/fixtures/ring_ding_active.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 429e92afa7f..5c9a644f6b7 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -103,7 +103,8 @@ class RingBinarySensor(BinarySensorDevice): self._data.check_alerts() if self._data.alert: - self._state = (self._sensor_type == - self._data.alert.get('kind')) + if self._sensor_type == self._data.alert.get('kind') and \ + self._data.account_id == self._data.alert.get('doorbot_id'): + self._state = True else: self._state = False diff --git a/tests/fixtures/ring_ding_active.json b/tests/fixtures/ring_ding_active.json index 6bbcc0ee3f9..7c9e0b07405 100644 --- a/tests/fixtures/ring_ding_active.json +++ b/tests/fixtures/ring_ding_active.json @@ -2,7 +2,7 @@ "audio_jitter_buffer_ms": 0, "device_kind": "lpd_v1", "doorbot_description": "Front Door", - "doorbot_id": 12345, + "doorbot_id": 987652, "expires_in": 180, "id": 123456789, "id_str": "123456789", From 8d5f6723ce66deb41dc507e8838d681cf5ae0f43 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 1 Sep 2017 09:15:47 +0200 Subject: [PATCH 097/277] =?UTF-8?q?Added=20configurable=20timeout=20for=20?= =?UTF-8?q?receiver=20HTTP=20requests=20|=20Additional=20AV=E2=80=A6=20(#9?= =?UTF-8?q?244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added configurable timeout for receiver HTTP requests | Additional AVR-X detection based on CommApiVers | Treat Marantz SR6007 - SR6010 as AVR-X device * timeout value not passed correctly --- .../components/media_player/denonavr.py | 18 ++++++++++++------ requirements_all.txt | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 06f95a7d3a7..94339514712 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -17,15 +17,16 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, - CONF_NAME, STATE_ON, CONF_ZONE) + CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.2'] +REQUIREMENTS = ['denonavr==0.5.3'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 2 CONF_SHOW_ALL_SOURCES = 'show_all_sources' CONF_ZONES = 'zones' CONF_VALID_ZONES = ['Zone2', 'Zone3'] @@ -51,7 +52,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, vol.Optional(CONF_ZONES): - vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]) + vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) NewHost = namedtuple('NewHost', ['host', 'name']) @@ -69,8 +71,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if cache is None: cache = hass.data[KEY_DENON_CACHE] = set() - # Get config option for show_all_sources + # Get config option for show_all_sources and timeout show_all_sources = config.get(CONF_SHOW_ALL_SOURCES) + timeout = config.get(CONF_TIMEOUT) # Get config option for additional zones zones = config.get(CONF_ZONES) @@ -103,14 +106,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for d_receiver in d_receivers: host = d_receiver["host"] name = d_receiver["friendlyName"] - new_hosts.append(NewHost(host=host, name=name)) + new_hosts.append( + NewHost(host=host, name=name)) for entry in new_hosts: # Check if host not in cache, append it and save for later # starting if entry.host not in cache: new_device = denonavr.DenonAVR( - entry.host, entry.name, show_all_sources, add_zones) + host=entry.host, name=entry.name, + show_all_inputs=show_all_sources, timeout=timeout, + add_zones=add_zones) for new_zone in new_device.zones.values(): receivers.append(DenonDevice(new_zone)) cache.add(host) diff --git a/requirements_all.txt b/requirements_all.txt index 823de83c610..08be6f5f556 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -166,7 +166,7 @@ datapoint==0.4.3 # decora_wifi==1.3 # homeassistant.components.media_player.denonavr -denonavr==0.5.2 +denonavr==0.5.3 # homeassistant.components.media_player.directv directpy==0.1 From 4cd5173ac8ac332b1640a649ac282c3f2be53695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 1 Sep 2017 11:58:26 +0200 Subject: [PATCH 098/277] upgrade xiaomi lib (#9250) --- homeassistant/components/xiaomi.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi.py index ce1149d0ece..1d14a76d251 100644 --- a/homeassistant/components/xiaomi.py +++ b/homeassistant/components/xiaomi.py @@ -9,7 +9,7 @@ from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' - '0.3.1.zip#PyXiaomiGateway==0.3.1'] + '0.3.2.zip#PyXiaomiGateway==0.3.2'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' diff --git a/requirements_all.txt b/requirements_all.txt index 08be6f5f556..de8a73b3bc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -299,7 +299,7 @@ holidays==0.8.1 http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a # homeassistant.components.xiaomi -https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.1.zip#PyXiaomiGateway==0.3.1 +https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.2.zip#PyXiaomiGateway==0.3.2 # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 From 713f7fa2a1300c637c5ec3340f9d03cb63bef6d8 Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Fri, 1 Sep 2017 12:02:22 +0200 Subject: [PATCH 099/277] Fix nello.io login (#9251) --- homeassistant/components/lock/nello.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lock/nello.py b/homeassistant/components/lock/nello.py index 47a8e3146aa..04030c92425 100644 --- a/homeassistant/components/lock/nello.py +++ b/homeassistant/components/lock/nello.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME) -REQUIREMENTS = ['pynello==1.5'] +REQUIREMENTS = ['pynello==1.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index de8a73b3bc0..a80b9de6d3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -667,7 +667,7 @@ pymyq==0.0.8 pymysensors==0.11.1 # homeassistant.components.lock.nello -pynello==1.5 +pynello==1.5.1 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 From 185d838803fe7e781e3817e5ed77bc7dcddf4dad Mon Sep 17 00:00:00 2001 From: snjoetw Date: Fri, 1 Sep 2017 03:08:30 -0700 Subject: [PATCH 100/277] This is to fix #6386: Manual Alarm not re-arm after 2nd trigger (#9249) --- .../components/alarm_control_panel/manual.py | 4 +- .../alarm_control_panel/test_manual.py | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 97820ab4b2b..d9cd6d6a9ac 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -101,7 +101,9 @@ class ManualAlarm(alarm.AlarmControlPanel): self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - return self._pre_trigger_state + else: + self._state = self._pre_trigger_state + return self._state return self._state diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index e5d819bc815..328ae4acd57 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -364,6 +364,97 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_trigger_with_no_disarm_after_trigger(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_back_to_back_trigger_with_no_disarm_after_trigger(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + def test_disarm_while_pending_trigger(self): """Test disarming while pending state.""" self.assertTrue(setup_component( From 4defd96cd62eecedad0e0c73aa96a8c33166bbaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=A6dstrup?= Date: Fri, 1 Sep 2017 15:27:43 +0200 Subject: [PATCH 101/277] Version bump of DLink switch to v0.6.0 (#9252) --- homeassistant/components/switch/dlink.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index b24693da616..e3d426f83a6 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -14,7 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN -REQUIREMENTS = ['pyW215==0.5.1'] +REQUIREMENTS = ['pyW215==0.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a80b9de6d3d..f77390e83d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ pyHS100==0.2.4.2 pyRFXtrx==0.20.0 # homeassistant.components.switch.dlink -pyW215==0.5.1 +pyW215==0.6.0 # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 From 8d1f6d399504fd3a581091d834bffbf0fbe463a7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 1 Sep 2017 18:05:37 +0200 Subject: [PATCH 102/277] Upgrade sendgrid to 5.2.0 (#9254) --- homeassistant/components/notify/sendgrid.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 545cddfadea..b7f192ff983 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.0.1'] +REQUIREMENTS = ['sendgrid==5.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f77390e83d2..41e3c2fba0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -881,7 +881,7 @@ schiene==0.18 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.0.1 +sendgrid==5.2.0 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat From 8797932f8046ef727bb2f43a14d402d95a5f17a4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 1 Sep 2017 18:05:53 +0200 Subject: [PATCH 103/277] Upgrade psutil to 5.3.0 (#9253) --- homeassistant/components/sensor/systemmonitor.py | 13 ++++++++----- requirements_all.txt | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 42229351fde..69a82fb0fac 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,10 +16,12 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.2.2'] +REQUIREMENTS = ['psutil==5.3.0'] _LOGGER = logging.getLogger(__name__) +CONF_ARG = 'arg' + SENSOR_TYPES = { 'disk_free': ['Disk Free', 'GiB', 'mdi:harddisk'], 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], @@ -49,7 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_RESOURCES, default=['disk_use']): vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional('arg'): cv.string, + vol.Optional(CONF_ARG): cv.string, })]) }) @@ -71,9 +73,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the system monitor sensors.""" dev = [] for resource in config[CONF_RESOURCES]: - if 'arg' not in resource: - resource['arg'] = '' - dev.append(SystemMonitorSensor(resource[CONF_TYPE], resource['arg'])) + if CONF_ARG not in resource: + resource[CONF_ARG] = '' + dev.append(SystemMonitorSensor( + resource[CONF_TYPE], resource[CONF_ARG])) add_devices(dev, True) diff --git a/requirements_all.txt b/requirements_all.txt index 41e3c2fba0c..cd66d4d8710 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,7 +510,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.2.2 +psutil==5.3.0 # homeassistant.components.wink pubnubsub-handler==1.0.2 From 639eb81aefc5656fc73f64138e27a50b7820e82e Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Fri, 1 Sep 2017 15:41:35 -0400 Subject: [PATCH 104/277] Adding ZWave CentralScene activation handler. (#9178) * Adding ZWave CentralScene activation handler. * Migrated CentralScene logic to node_entity.py Removed extraneous logging Modified scene_activated event to send the scene_id and scene_data separately * Adding unit test for ZWave central scene activation * Removed return to allow node statistics to update after central scene message is received --- homeassistant/components/zwave/const.py | 1 + homeassistant/components/zwave/node_entity.py | 25 +++++++-- tests/components/zwave/test_node_entity.py | 54 +++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index b72d9eb0cff..a238d01d520 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -10,6 +10,7 @@ ATTR_VALUE_ID = "value_id" ATTR_OBJECT_ID = "object_id" ATTR_NAME = "name" ATTR_SCENE_ID = "scene_id" +ATTR_SCENE_DATA = "scene_data" ATTR_BASIC_LEVEL = "basic_level" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_SIZE = "size" diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 3a810d00d2d..44a30cdc529 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -7,8 +7,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from .const import ( - ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_BASIC_LEVEL, - EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN) + ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA, + ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN, + COMMAND_CLASS_CENTRAL_SCENE) from .util import node_name _LOGGER = logging.getLogger(__name__) @@ -107,13 +108,19 @@ class ZWaveNodeEntity(ZWaveBaseEntity): dispatcher.connect( self.network_scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT) - def network_node_changed(self, node=None, args=None): + def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" if node and node.node_id != self.node_id: return if args is not None and 'nodeId' in args and \ args['nodeId'] != self.node_id: return + + # Process central scene activation + if (value is not None and + value.command_class == COMMAND_CLASS_CENTRAL_SCENE): + self.central_scene_activated(value.index, value.data) + self.node_changed() def get_node_statistics(self): @@ -177,6 +184,18 @@ class ZWaveNodeEntity(ZWaveBaseEntity): ATTR_SCENE_ID: scene_id }) + def central_scene_activated(self, scene_id, scene_data): + """Handle an activated central scene for this node.""" + if self.hass is None: + return + + self.hass.bus.fire(EVENT_SCENE_ACTIVATED, { + ATTR_ENTITY_ID: self.entity_id, + ATTR_NODE_ID: self.node_id, + ATTR_SCENE_ID: scene_id, + ATTR_SCENE_DATA: scene_data + }) + @property def state(self): """Return the state.""" diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index b7148dd982e..32351234ad3 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -117,6 +117,60 @@ def test_scene_activated(hass, mock_openzwave): assert events[0].data[const.ATTR_SCENE_ID] == scene_id +@asyncio.coroutine +def test_central_scene_activated(hass, mock_openzwave): + """Test central scene activated event.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == mock_zwave.MockNetwork.SIGNAL_VALUE_CHANGED: + mock_receivers.append(receiver) + + node = mock_zwave.MockNode(node_id=11) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + entity = node_entity.ZWaveNodeEntity(node, mock_openzwave, True) + + assert len(mock_receivers) == 1 + + events = [] + + def listener(event): + events.append(event) + + hass.bus.async_listen(const.EVENT_SCENE_ACTIVATED, listener) + + # Test event before entity added to hass + scene_id = 1 + scene_data = 3 + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_CENTRAL_SCENE, + index=scene_id, + data=scene_data) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + assert len(events) == 0 + + # Add entity to hass + entity.hass = hass + entity.entity_id = 'zwave.mock_node' + + scene_id = 1 + scene_data = 3 + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_CENTRAL_SCENE, + index=scene_id, + data=scene_data) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" + assert events[0].data[const.ATTR_NODE_ID] == 11 + assert events[0].data[const.ATTR_SCENE_ID] == scene_id + assert events[0].data[const.ATTR_SCENE_DATA] == scene_data + + @pytest.mark.usefixtures('mock_openzwave') class TestZWaveNodeEntity(unittest.TestCase): """Class to test ZWaveNodeEntity.""" From f51163f803e408fab0bf7aaab541752d9e66af63 Mon Sep 17 00:00:00 2001 From: Gunnar Helgason Date: Fri, 1 Sep 2017 23:56:59 +0200 Subject: [PATCH 105/277] Add Geofency device tracker (#9106) * Added Geofency device tracker Added Geofency device tracker * fix pylint error * review fixes * merge coroutines --- .../components/device_tracker/geofency.py | 127 ++++++++++ .../device_tracker/test_geofency.py | 230 ++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100755 homeassistant/components/device_tracker/geofency.py create mode 100644 tests/components/device_tracker/test_geofency.py diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py new file mode 100755 index 00000000000..d4e576bad74 --- /dev/null +++ b/homeassistant/components/device_tracker/geofency.py @@ -0,0 +1,127 @@ +""" +Support for the Geofency platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.geofency/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +BEACON_DEV_PREFIX = 'beacon' +CONF_MOBILE_BEACONS = 'mobile_beacons' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MOBILE_BEACONS): vol.All( + cv.ensure_list, [cv.string]), +}) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up an endpoint for the Geofency application.""" + mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] + + hass.http.register_view(GeofencyView(see, mobile_beacons)) + + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, see, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.see = see + self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] + + @asyncio.coroutine + def post(self, request): + """Handle Geofency requests.""" + data = yield from request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) + + if self._is_mobile_beacon(data): + return (yield from self._set_location(hass, data, None)) + else: + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + + return (yield from self._set_location(hass, data, location_name)) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return False + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE]) + data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + else: + return data['device'] + + @asyncio.coroutine + def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + yield from hass.async_add_job( + partial(self.see, dev_id=device, + gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name=location_name, + attributes=data)) + + return "Setting location for {}".format(device) diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/device_tracker/test_geofency.py new file mode 100644 index 00000000000..e8aa44cb0e5 --- /dev/null +++ b/tests/components/device_tracker/test_geofency.py @@ -0,0 +1,230 @@ +"""The tests for the Geofency device tracker platform.""" +# pylint: disable=redefined-outer-name +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components import zone +import homeassistant.components.device_tracker as device_tracker +from homeassistant.components.device_tracker.geofency import ( + CONF_MOBILE_BEACONS, URL) +from homeassistant.const import ( + CONF_PLATFORM, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, + STATE_NOT_HOME) +from homeassistant.setup import async_setup_component +from homeassistant.util import slugify + +HOME_LATITUDE = 37.239622 +HOME_LONGITUDE = -115.815811 + +NOT_HOME_LATITUDE = 37.239394 +NOT_HOME_LONGITUDE = -115.763283 + +GPS_ENTER_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +GPS_EXIT_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + +BEACON_ENTER_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +BEACON_EXIT_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + +BEACON_ENTER_CAR = { + 'latitude': NOT_HOME_LATITUDE, + 'longitude': NOT_HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Car 1', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +BEACON_EXIT_CAR = { + 'latitude': NOT_HOME_LATITUDE, + 'longitude': NOT_HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Car 1', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + + +@pytest.fixture +def geofency_client(loop, hass, test_client): + """Geofency mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'geofency', + CONF_MOBILE_BEACONS: ['Car 1'] + }})) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture(autouse=True) +def setup_zones(loop, hass): + """Setup Zone config in HA.""" + assert loop.run_until_complete(async_setup_component( + hass, zone.DOMAIN, { + 'zone': { + 'name': 'Home', + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'radius': 100, + }})) + + +@asyncio.coroutine +def test_data_validation(geofency_client): + """Test data validation.""" + # No data + req = yield from geofency_client.post(URL) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + missing_attributes = ['address', 'device', + 'entry', 'latitude', 'longitude', 'name'] + + # missing attributes + for attribute in missing_attributes: + copy = GPS_ENTER_HOME.copy() + del copy[attribute] + req = yield from geofency_client.post(URL, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + +@asyncio.coroutine +def test_gps_enter_and_exit_home(hass, geofency_client): + """Test GPS based zone enter and exit.""" + # Enter the Home zone + req = yield from geofency_client.post(URL, data=GPS_ENTER_HOME) + assert req.status == HTTP_OK + device_name = slugify(GPS_ENTER_HOME['device']) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Home zone + req = yield from geofency_client.post(URL, data=GPS_EXIT_HOME) + assert req.status == HTTP_OK + device_name = slugify(GPS_EXIT_HOME['device']) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + +@asyncio.coroutine +def test_beacon_enter_and_exit_home(hass, geofency_client): + """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" + # Enter the Home zone + req = yield from geofency_client.post(URL, data=BEACON_ENTER_HOME) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Home zone + req = yield from geofency_client.post(URL, data=BEACON_EXIT_HOME) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + +@asyncio.coroutine +def test_beacon_enter_and_exit_car(hass, geofency_client): + """Test use of mobile iBeacon.""" + # Enter the Car away from Home zone + req = yield from geofency_client.post(URL, data=BEACON_ENTER_CAR) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + # Exit the Car away from Home zone + req = yield from geofency_client.post(URL, data=BEACON_EXIT_CAR) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + # Enter the Car in the Home zone + data = BEACON_ENTER_CAR.copy() + data['latitude'] = HOME_LATITUDE + data['longitude'] = HOME_LONGITUDE + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(data['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Car in the Home zone + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(data['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name From 0889e38cb182c92ec7e880cf1fecdf115cb493b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 2 Sep 2017 17:02:11 +0100 Subject: [PATCH 106/277] flux: fix for when stop_time is after midnight (#8932) * flux: fix for when stop_time is after midnight * flux: fix imports * flux: add missing check when now is after midnight * flux: one more try; should fix all use cases now * flux switch: fix lint * flux switch: add new tests * flux switch: fix tests lint * flux switch: fix tests docstrings --- homeassistant/components/switch/flux.py | 38 +++- tests/components/switch/test_flux.py | 255 ++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 5613bcbb19e..e8bd592cee8 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -6,8 +6,9 @@ The idea was taken from https://github.com/KpaBap/hue-flux/ For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.flux/ """ -from datetime import time +import datetime import logging + import voluptuous as vol from homeassistant.components.light import is_on, turn_on @@ -46,7 +47,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_LIGHTS): cv.entity_ids, vol.Optional(CONF_NAME, default="Flux"): cv.string, vol.Optional(CONF_START_TIME): cv.time, - vol.Optional(CONF_STOP_TIME, default=time(22, 0)): cv.time, + vol.Optional(CONF_STOP_TIME, default=datetime.time(22, 0)): cv.time, vol.Optional(CONF_START_CT, default=4000): vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)), vol.Optional(CONF_SUNSET_CT, default=3000): @@ -171,12 +172,22 @@ class FluxSwitch(SwitchDevice): """Update all the lights using flux.""" if now is None: now = dt_now() + sunset = get_astral_event_date(self.hass, 'sunset', now.date()) start_time = self.find_start_time(now) stop_time = now.replace( hour=self._stop_time.hour, minute=self._stop_time.minute, second=0) + if stop_time <= start_time: + # stop_time does not happen in the same day as start_time + if start_time < now: + # stop time is tomorrow + stop_time += datetime.timedelta(days=1) + elif now < start_time: + # stop_time was yesterday since the new start_time is not reached + stop_time -= datetime.timedelta(days=1) + if start_time < now < sunset: # Daytime time_state = 'day' @@ -192,15 +203,24 @@ class FluxSwitch(SwitchDevice): else: # Nightime time_state = 'night' - if now < stop_time and now > start_time: - now_time = now + + if now < stop_time: + if stop_time < start_time and stop_time.day == sunset.day: + # we need to use yesterday's sunset time + sunset_time = sunset - datetime.timedelta(days=1) + else: + sunset_time = sunset + + # pylint: disable=no-member + night_length = int(stop_time.timestamp() - + sunset_time.timestamp()) + seconds_from_sunset = int(now.timestamp() - + sunset_time.timestamp()) + percentage_complete = seconds_from_sunset / night_length else: - now_time = stop_time + percentage_complete = 1 + temp_range = abs(self._sunset_colortemp - self._stop_colortemp) - night_length = int(stop_time.timestamp() - sunset.timestamp()) - seconds_from_sunset = int(now_time.timestamp() - - sunset.timestamp()) - percentage_complete = seconds_from_sunset / night_length temp_offset = temp_range * percentage_complete if self._sunset_colortemp > self._stop_colortemp: temp = self._sunset_colortemp - temp_offset diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index d529e8c3f56..0d2a486cb4f 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -347,6 +347,261 @@ class TestSwitchFlux(unittest.TestCase): self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 154) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.494, 0.397]) + def test_flux_before_sunrise_stop_next_day(self): + """Test the flux switch before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + + # pylint: disable=invalid-name + def test_flux_after_sunrise_before_sunset_stop_next_day(self): + """ + Test the flux switch after sunrise and before sunset. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 180) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.431, 0.38]) + + # pylint: disable=invalid-name + def test_flux_after_sunset_before_midnight_stop_next_day(self): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=23, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 126) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.574, 0.401]) + + # pylint: disable=invalid-name + def test_flux_after_sunset_after_midnight_stop_next_day(self): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=00, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 122) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.586, 0.397]) + + # pylint: disable=invalid-name + def test_flux_after_stop_before_sunrise_stop_next_day(self): + """Test the flux switch after stop and before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + # pylint: disable=invalid-name def test_flux_with_custom_colortemps(self): """Test the flux with custom start and stop colortemps.""" From a78f5e0970953bf14978f3558f1ee0aaacd1420c Mon Sep 17 00:00:00 2001 From: pavoni Date: Sun, 3 Sep 2017 11:31:55 +0100 Subject: [PATCH 107/277] Bump pywemo, handle more ports. --- homeassistant/components/wemo.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 3d7226e3c8b..0592ad4c124 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.19'] +REQUIREMENTS = ['pywemo==0.4.20'] DOMAIN = 'wemo' diff --git a/requirements_all.txt b/requirements_all.txt index cd66d4d8710..df92881ccb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -827,7 +827,7 @@ pyvlx==0.1.3 pywebpush==1.0.6 # homeassistant.components.wemo -pywemo==0.4.19 +pywemo==0.4.20 # homeassistant.components.zabbix pyzabbix==0.7.4 From 7694c31814dd4286fd808efb25d04fc61bb4506c Mon Sep 17 00:00:00 2001 From: emlt Date: Sun, 3 Sep 2017 16:07:12 +0200 Subject: [PATCH 108/277] Change attribute names (#9277) Remove spaces and capitals in attribute names to be consistent with sensors and other switches. --- homeassistant/components/switch/dlink.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index e3d426f83a6..f6ed6dac018 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -23,9 +23,9 @@ DEFAULT_PASSWORD = '' DEFAULT_USERNAME = 'admin' CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol' -ATTR_CURRENT_CONSUMPTION = 'Current Consumption' -ATTR_TOTAL_CONSUMPTION = 'Total Consumption' -ATTR_TEMPERATURE = 'Temperature' +ATTR_CURRENT_CONSUMPTION = 'power_consumption' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TEMPERATURE = 'temperature' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, From 68343ac81f4ab62cf8000ecf56592172f70bf756 Mon Sep 17 00:00:00 2001 From: Dan Ports Date: Sun, 3 Sep 2017 12:42:05 -0700 Subject: [PATCH 109/277] insteon_plm: fix typo in attributes (#9284) --- homeassistant/components/insteon_plm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 92807bf9b1c..94b70e47cba 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -102,7 +102,7 @@ def common_attributes(entity): 'address': 'INSTEON Address', 'description': 'Description', 'model': 'Model', - 'cat': 'Cagegory', + 'cat': 'Category', 'subcat': 'Subcategory', 'firmware': 'Firmware', 'product_key': 'Product Key' From 38e1b81ff67be6f9178e6e403431389e4dd17ee3 Mon Sep 17 00:00:00 2001 From: Paul Sokolovsky Date: Sun, 3 Sep 2017 23:27:13 +0300 Subject: [PATCH 110/277] discovery: If unknown NetDisco service discovered, log about it. (#9280) Otherwise, known services are logged, ignored are logged, but unknown - not. Logging them is quite helpful for someone working on adding new discovery service to NetDisco/HA, and would help to decouple NetDisco library further: another project may use a generic NetDisco library, and contribute new service to it, which won't be automatically supported by HA. But logging about it would be a good hint to HA users that they can look into supporting it. --- homeassistant/components/discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 06e6f0b989a..c757d9d1ce3 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -100,6 +100,7 @@ def async_setup(hass, config): # We do not know how to handle this service. if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) return discovery_hash = json.dumps([service, info], sort_keys=True) From 5dfd60a029f96e8643deb201090657b92497f8fa Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 3 Sep 2017 17:21:35 -0400 Subject: [PATCH 111/277] Upgrade youtube_dl to 2017.9.2 (#9279) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index f1d6139ffb1..1ecb09ac022 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.8.18'] +REQUIREMENTS = ['youtube_dl==2017.9.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index df92881ccb6..742d2f3cc83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1029,7 +1029,7 @@ yeelight==0.3.2 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.8.18 +youtube_dl==2017.9.2 # homeassistant.components.light.zengge zengge==0.2 From 7c7a5a4a15e28c2ce34cd914562149929daabc14 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 3 Sep 2017 17:21:51 -0400 Subject: [PATCH 112/277] Upgrade python-telegram-bot to 8.0.0 (#9282) --- homeassistant/components/telegram_bot/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 38669ff4ee6..de9c0f4ede3 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==7.0.1'] +REQUIREMENTS = ['python-telegram-bot==8.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 742d2f3cc83..ae91099165b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ python-synology==0.1.0 python-tado==0.2.2 # homeassistant.components.telegram_bot -python-telegram-bot==7.0.1 +python-telegram-bot==8.0.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 From e6207684bf3fb0fbe94bc2455a5c7b6f53e3ff8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 4 Sep 2017 10:19:58 +0200 Subject: [PATCH 113/277] rfxtrx lib upgrade (#9288) --- homeassistant/components/rfxtrx.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 259f8fa8ac6..0c5acd3f7fa 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyRFXtrx==0.20.0'] +REQUIREMENTS = ['pyRFXtrx==0.20.1'] DOMAIN = 'rfxtrx' diff --git a/requirements_all.txt b/requirements_all.txt index ae91099165b..89f42d89979 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -536,7 +536,7 @@ pyCEC==0.4.13 pyHS100==0.2.4.2 # homeassistant.components.rfxtrx -pyRFXtrx==0.20.0 +pyRFXtrx==0.20.1 # homeassistant.components.switch.dlink pyW215==0.6.0 From 1b5e574a76a6e0d7dc55bfa6a58b43ee01159fcc Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Mon, 4 Sep 2017 13:34:56 +0200 Subject: [PATCH 114/277] Fixing bug when using egardiaserver - package requirement updated to 1.0.20. (#9294) * Bumping pythonegardia package requirement up to .18 * Updating requirements_all to reflect updated pythonegardia package .18 * Catching up with reality and updating egardia.py Requirements_all reflects updated package requirement for python-egardia of 1.0.20 --- homeassistant/components/alarm_control_panel/egardia.py | 7 ++++--- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 1ef5e5d64d8..fbafe061334 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.19'] +REQUIREMENTS = ['pythonegardia==1.0.20'] _LOGGER = logging.getLogger(__name__) @@ -154,8 +154,9 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def update(self): """Update the alarm status.""" - status = self._egardiasystem.getstate() - self.parsestatus(status) + if not self._rs_enabled: + status = self._egardiasystem.getstate() + self.parsestatus(status) def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/requirements_all.txt b/requirements_all.txt index 89f42d89979..664ca45a82b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -800,7 +800,7 @@ python-wink==1.5.1 python_openzwave==0.4.0.31 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.19 +pythonegardia==1.0.20 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From 54de3d89d1f7987707215ccdc5f8262d5f035b2b Mon Sep 17 00:00:00 2001 From: Andreas Jacobsen Date: Mon, 4 Sep 2017 13:40:08 +0200 Subject: [PATCH 115/277] Added intent_type to exception log (#9289) --- homeassistant/components/snips.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 6243de0b2d6..1f64f78e9c8 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -66,7 +66,7 @@ def async_setup(hass, config): yield from intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) except intent.IntentError: - _LOGGER.exception("Error while handling intent.") + _LOGGER.exception("Error while handling intent: %s.", intent_type) yield from hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) From 67828cb7a2c4995eefb0b6ee51269ba322b9dbf8 Mon Sep 17 00:00:00 2001 From: Tom Matheussen Date: Mon, 4 Sep 2017 20:47:40 +0200 Subject: [PATCH 116/277] Handle spotify failing to refresh access_token (#9295) * Handle spotify failing to refresh access_token * Remove whitespace --- homeassistant/components/media_player/spotify.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 239b13a6292..734285d918a 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -148,6 +148,10 @@ class SpotifyMediaPlayer(MediaPlayerDevice): new_token = \ self._oauth.refresh_access_token( self._token_info['refresh_token']) + # skip when refresh failed + if new_token is None: + return + self._token_info = new_token token_refreshed = True if self._player is None or token_refreshed: @@ -158,6 +162,12 @@ class SpotifyMediaPlayer(MediaPlayerDevice): def update(self): """Update state and attributes.""" self.refresh_spotify_instance() + + # Don't true update when token is expired + if self._oauth.is_token_expired(self._token_info): + _LOGGER.warning("Spotify failed to update, token expired.") + return + # Available devices player_devices = self._player.devices() if player_devices is not None: From ed699896cb5965afb4e56cc40b6f3a0ef873967f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 5 Sep 2017 02:01:01 +0200 Subject: [PATCH 117/277] Core track same state for a period / Allow on platforms (#9273) * Core track state period / Allow on platforms * Add tests * fix lint * fix tests * add new tracker to automation state * update schema * fix bug * revert validate string * Fix bug * Set arguments to async_check_funct * add logic into numeric_state * fix numeric_state * Add tests * fix retrigger state * cleanup * Add delay function to template binary_sensor * Fix tests & lint * add more tests * fix lint * Address comments * fix test & lint --- .../components/automation/numeric_state.py | 66 +++++++-- homeassistant/components/automation/state.py | 74 +++------ .../components/binary_sensor/template.py | 59 ++++++-- homeassistant/const.py | 1 + homeassistant/helpers/event.py | 56 +++++++ .../automation/test_numeric_state.py | 132 ++++++++++++++++- .../components/binary_sensor/test_template.py | 140 +++++++++++++++++- tests/helpers/test_event.py | 108 +++++++++++++- 8 files changed, 548 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 3657724f679..51b2ea89f0f 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -12,16 +12,18 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, - CONF_BELOW, CONF_ABOVE) -from homeassistant.helpers.event import async_track_state_change + CONF_BELOW, CONF_ABOVE, CONF_FOR) +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers import condition, config_validation as cv TRIGGER_SCHEMA = vol.All(vol.Schema({ vol.Required(CONF_PLATFORM): 'numeric_state', vol.Required(CONF_ENTITY_ID): cv.entity_ids, - CONF_BELOW: vol.Coerce(float), - CONF_ABOVE: vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Optional(CONF_ABOVE): vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), }), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) _LOGGER = logging.getLogger(__name__) @@ -33,15 +35,18 @@ def async_trigger(hass, config, action): entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) + time_delta = config.get(CONF_FOR) value_template = config.get(CONF_VALUE_TEMPLATE) + async_remove_track_same = None + if value_template is not None: value_template.hass = hass @callback - def state_automation_listener(entity, from_s, to_s): - """Listen for state changes and calls action.""" + def check_numeric_state(entity, from_s, to_s): + """Return True if they should trigger.""" if to_s is None: - return + return False variables = { 'trigger': { @@ -55,17 +60,56 @@ def async_trigger(hass, config, action): # If new one doesn't match, nothing to do if not condition.async_numeric_state( hass, to_s, below, above, value_template, variables): + return False + + return True + + @callback + def state_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + nonlocal async_remove_track_same + + if not check_numeric_state(entity, from_s, to_s): return + variables = { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + 'from_state': from_s, + 'to_state': to_s, + } + } + # Only match if old didn't exist or existed but didn't match # Written as: skip if old one did exist and matched if from_s is not None and condition.async_numeric_state( hass, from_s, below, above, value_template, variables): return - variables['trigger']['from_state'] = from_s - variables['trigger']['to_state'] = to_s + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action, variables) - hass.async_run_job(action, variables) + if not time_delta: + call_action() + return - return async_track_state_change(hass, entity_id, state_automation_listener) + async_remove_track_same = async_track_same_state( + hass, True, time_delta, call_action, entity_ids=entity_id, + async_check_func=check_numeric_state) + + unsub = async_track_state_change( + hass, entity_id, state_automation_listener) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable + + return async_remove diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 8ad5c40bb80..e7a01cb7115 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -8,28 +8,23 @@ import asyncio import voluptuous as vol from homeassistant.core import callback -import homeassistant.util.dt as dt_util -from homeassistant.const import MATCH_ALL, CONF_PLATFORM +from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR from homeassistant.helpers.event import ( - async_track_state_change, async_track_point_in_utc_time) + async_track_state_change, async_track_same_state) import homeassistant.helpers.config_validation as cv CONF_ENTITY_ID = 'entity_id' CONF_FROM = 'from' CONF_TO = 'to' -CONF_FOR = 'for' -TRIGGER_SCHEMA = vol.All( - vol.Schema({ - vol.Required(CONF_PLATFORM): 'state', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - # These are str on purpose. Want to catch YAML conversions - CONF_FROM: str, - CONF_TO: str, - CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta), - }), - cv.key_dependency(CONF_FOR, CONF_TO), -) +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'state', + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + # These are str on purpose. Want to catch YAML conversions + vol.Optional(CONF_FROM): str, + vol.Optional(CONF_TO): str, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), +}), cv.key_dependency(CONF_FOR, CONF_TO)) @asyncio.coroutine @@ -39,28 +34,15 @@ def async_trigger(hass, config, action): from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO, MATCH_ALL) 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(): - """Clear all unsub listener.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener - - # pylint: disable=not-callable - if async_remove_state_for_listener is not None: - async_remove_state_for_listener() - async_remove_state_for_listener = None - if async_remove_state_for_cancel is not None: - async_remove_state_for_cancel() - async_remove_state_for_cancel = None + async_remove_track_same = None @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + nonlocal async_remove_track_same + @callback def call_action(): """Call action with right context.""" hass.async_run_job(action, { @@ -78,33 +60,12 @@ def async_trigger(hass, config, action): from_s.last_changed == to_s.last_changed): return - if time_delta is None: + if not time_delta: call_action() return - @callback - def state_for_listener(now): - """Fire on state changes after a delay and calls action.""" - nonlocal async_remove_state_for_listener - async_remove_state_for_listener = None - clear_listener() - call_action() - - @callback - def state_for_cancel_listener(entity, inner_from_s, inner_to_s): - """Fire on changes and cancel for listener if changed.""" - if inner_to_s.state == to_s.state: - return - clear_listener() - - # cleanup previous listener - clear_listener() - - async_remove_state_for_listener = async_track_point_in_utc_time( - hass, state_for_listener, dt_util.utcnow() + time_delta) - - async_remove_state_for_cancel = async_track_state_change( - hass, entity, state_for_cancel_listener) + async_remove_track_same = async_track_same_state( + hass, to_s.state, time_delta, call_action, entity_ids=entity_id) unsub = async_track_state_change( hass, entity_id, state_automation_listener, from_state, to_state) @@ -113,6 +74,7 @@ def async_trigger(hass, config, action): def async_remove(): """Remove state listeners async.""" unsub() - clear_listener() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable return async_remove diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 330e8eaea9d..413804f0856 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -19,16 +19,24 @@ from homeassistant.const import ( 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.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) +CONF_DELAY_ON = 'delay_on' +CONF_DELAY_OFF = 'delay_off' + SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DELAY_ON): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DELAY_OFF): + vol.All(cv.time_period, cv.positive_timedelta), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,6 +55,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): value_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) + delay_on = device_config.get(CONF_DELAY_ON) + delay_off = device_config.get(CONF_DELAY_OFF) if value_template is not None: value_template.hass = hass @@ -54,13 +64,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensors.append( BinarySensorTemplate( hass, device, friendly_name, device_class, value_template, - entity_ids) + entity_ids, delay_on, delay_off) ) if not sensors: _LOGGER.error("No sensors added") return False - async_add_devices(sensors, True) + async_add_devices(sensors) return True @@ -68,7 +78,7 @@ class BinarySensorTemplate(BinarySensorDevice): """A virtual binary sensor that triggers from another sensor.""" def __init__(self, hass, device, friendly_name, device_class, - value_template, entity_ids): + value_template, entity_ids, delay_on, delay_off): """Initialize the Template binary sensor.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -78,6 +88,8 @@ class BinarySensorTemplate(BinarySensorDevice): self._template = value_template self._state = None self._entities = entity_ids + self._delay_on = delay_on + self._delay_off = delay_off @asyncio.coroutine def async_added_to_hass(self): @@ -89,7 +101,7 @@ class BinarySensorTemplate(BinarySensorDevice): @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_check_state() @callback def template_bsensor_startup(event): @@ -97,7 +109,7 @@ class BinarySensorTemplate(BinarySensorDevice): async_track_state_change( self.hass, self._entities, template_bsensor_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.hass.async_add_job(self.async_check_state) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_bsensor_startup) @@ -122,11 +134,11 @@ class BinarySensorTemplate(BinarySensorDevice): """No polling needed.""" return False - @asyncio.coroutine - def async_update(self): - """Update the state from the template.""" + @callback + def _async_render(self, *args): + """Get the state of template.""" try: - self._state = self._template.async_render().lower() == 'true' + return self._template.async_render().lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): @@ -135,4 +147,29 @@ class BinarySensorTemplate(BinarySensorDevice): "the state is unknown", self._name) return _LOGGER.error("Could not render template %s: %s", self._name, ex) - self._state = False + + @callback + def async_check_state(self): + """Update the state from the template.""" + state = self._async_render() + + # return if the state don't change or is invalid + if state is None or state == self.state: + return + + @callback + def set_state(): + """Set state of template binary sensor.""" + self._state = state + self.hass.async_add_job(self.async_update_ha_state()) + + # state without delay + if (state and not self._delay_on) or \ + (not state and not self._delay_off): + set_state() + return + + period = self._delay_on if state else self._delay_off + async_track_same_state( + self.hass, state, period, set_state, entity_ids=self._entities, + async_check_func=self._async_render) diff --git a/homeassistant/const.py b/homeassistant/const.py index dd8a579b033..88ab58201f8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -101,6 +101,7 @@ CONF_EVENT = 'event' CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' +CONF_FOR = 'for' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9b64c08af18..5db4ece5ef5 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -113,6 +113,62 @@ def async_track_template(hass, template, action, variables=None): track_template = threaded_listener_factory(async_track_template) +@callback +def async_track_same_state(hass, orig_value, period, action, + async_check_func=None, entity_ids=MATCH_ALL): + """Track the state of entities for a period and run a action. + + If async_check_func is None it use the state of orig_value. + Without entity_ids we track all state changes. + """ + async_remove_state_for_cancel = None + async_remove_state_for_listener = None + + @callback + def clear_listener(): + """Clear all unsub listener.""" + nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + + # pylint: disable=not-callable + if async_remove_state_for_listener is not None: + async_remove_state_for_listener() + async_remove_state_for_listener = None + if async_remove_state_for_cancel is not None: + async_remove_state_for_cancel() + async_remove_state_for_cancel = None + + @callback + def state_for_listener(now): + """Fire on state changes after a delay and calls action.""" + nonlocal async_remove_state_for_listener + async_remove_state_for_listener = None + clear_listener() + hass.async_run_job(action) + + @callback + def state_for_cancel_listener(entity, from_state, to_state): + """Fire on changes and cancel for listener if changed.""" + if async_check_func: + value = async_check_func(entity, from_state, to_state) + else: + value = to_state.state + + if orig_value == value: + return + clear_listener() + + async_remove_state_for_listener = async_track_point_in_utc_time( + hass, state_for_listener, dt_util.utcnow() + period) + + async_remove_state_for_cancel = async_track_state_change( + hass, entity_ids, state_for_cancel_listener) + + return clear_listener + + +track_same_state = threaded_listener_factory(async_track_same_state) + + @callback def async_track_point_in_time(hass, action, point_in_time): """Add a listener that fires once after a specific point in time.""" diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 355e26abf9b..0a7db4a122d 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -1,11 +1,16 @@ """The tests for numeric state automation.""" +from datetime import timedelta import unittest +from unittest.mock import patch +import homeassistant.components.automation as automation from homeassistant.core import callback from homeassistant.setup import setup_component -import homeassistant.components.automation as automation +import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant, mock_component +from tests.common import ( + get_test_home_assistant, mock_component, fire_time_changed, + assert_setup_component) # pylint: disable=invalid-name @@ -576,3 +581,126 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(2, len(self.calls)) + + def test_if_fails_setup_bad_for(self): + """Test for setup failure for bad for.""" + with assert_setup_component(0): + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'invalid': 5 + }, + }, + 'action': { + 'service': 'homeassistant.turn_on', + } + }}) + + def test_if_fails_setup_for_without_above_below(self): + """Test for setup failures for missing above or below.""" + with assert_setup_component(0): + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'homeassistant.turn_on', + } + }}) + + def test_if_not_fires_on_entity_change_with_for(self): + """Test for not firing on entity change with for.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + self.hass.states.set('test.entity', 15) + self.hass.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_entity_change_with_for_attribute_change(self): + """Test for firing on entity change with for and attribute change.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + utcnow = dt_util.utcnow() + with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: + mock_utcnow.return_value = utcnow + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.states.set('test.entity', 9, + attributes={"mock_attr": "attr_change"}) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_with_for(self): + """Test for firing on entity change with for.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 4e829b42fe3..11163d42ab5 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -1,5 +1,6 @@ """The tests for the Template Binary sensor platform.""" import asyncio +from datetime import timedelta import unittest from unittest import mock @@ -10,10 +11,12 @@ from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError from homeassistant.helpers import template as template_hlpr from homeassistant.util.async import run_callback_threadsafe +import homeassistant.util.dt as dt_util from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_component) + get_test_home_assistant, assert_setup_component, mock_component, + async_fire_time_changed) class TestBinarySensorTemplate(unittest.TestCase): @@ -103,19 +106,20 @@ class TestBinarySensorTemplate(unittest.TestCase): vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL + template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL, + None, None ).result() self.assertFalse(vs.should_poll) self.assertEqual('motion', vs.device_class) self.assertEqual('Parent', vs.name) - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() self.assertFalse(vs.is_on) # pylint: disable=protected-access vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass) - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() self.assertTrue(vs.is_on) def test_event(self): @@ -155,13 +159,14 @@ class TestBinarySensorTemplate(unittest.TestCase): vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL + template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL, + None, None ).result() mock_render.side_effect = TemplateError('foo') - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() mock_render.side_effect = TemplateError( "UndefinedError: 'None' has no attribute") - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() @asyncio.coroutine @@ -197,3 +202,124 @@ def test_restore_state(hass): state = hass.states.get('binary_sensor.test') assert state.state == 'off' + + +@asyncio.coroutine +def test_template_delay_on(hass): + """Test binary sensor template delay on.""" + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + 'delay_on': 5 + }, + }, + }, + } + yield from setup.async_setup_component(hass, 'binary_sensor', config) + yield from hass.async_start() + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + # check with time changes + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + +@asyncio.coroutine +def test_template_delay_off(hass): + """Test binary sensor template delay off.""" + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + 'delay_off': 5 + }, + }, + }, + } + hass.states.async_set('sensor.test_state', 'on') + yield from setup.async_setup_component(hass, 'binary_sensor', config) + yield from hass.async_start() + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + # check with time changes + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 37ff8ba297e..9c325df181e 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -17,6 +17,7 @@ from homeassistant.helpers.event import ( track_state_change, track_time_interval, track_template, + track_same_state, track_sunrise, track_sunset, ) @@ -24,7 +25,7 @@ from homeassistant.helpers.template import Template from homeassistant.components import sun import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, fire_time_changed from unittest.mock import patch @@ -262,6 +263,111 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(wildcard_runs)) self.assertEqual(2, len(wildercard_runs)) + def test_track_same_state_simple_trigger(self): + """Test track_same_change with trigger simple.""" + thread_runs = [] + callback_runs = [] + coroutine_runs = [] + period = timedelta(minutes=1) + + def thread_run_callback(): + thread_runs.append(1) + + track_same_state( + self.hass, 'on', period, thread_run_callback, + entity_ids='light.Bowl') + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl') + + @asyncio.coroutine + def coroutine_run_callback(): + coroutine_runs.append(1) + + track_same_state( + self.hass, 'on', period, coroutine_run_callback) + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(thread_runs)) + self.assertEqual(0, len(callback_runs)) + self.assertEqual(0, len(coroutine_runs)) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(1, len(thread_runs)) + self.assertEqual(1, len(callback_runs)) + self.assertEqual(1, len(coroutine_runs)) + + def test_track_same_state_simple_no_trigger(self): + """Test track_same_change with no trigger.""" + callback_runs = [] + period = timedelta(minutes=1) + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl') + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + # Change state on state machine + self.hass.states.set("light.Bowl", "off") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + def test_track_same_state_simple_trigger_check_funct(self): + """Test track_same_change with trigger and check funct.""" + callback_runs = [] + check_func = [] + period = timedelta(minutes=1) + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + @ha.callback + def async_check_func(entity, from_s, to_s): + check_func.append((entity, from_s, to_s)) + return 'on' + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl', async_check_func=async_check_func) + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + self.assertEqual('on', check_func[-1][2].state) + self.assertEqual('light.bowl', check_func[-1][0]) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(1, len(callback_runs)) + def test_track_time_interval(self): """Test tracking time interval.""" specific_runs = [] From c3a91000ac6e659f63343ee5cace204429a7c5b5 Mon Sep 17 00:00:00 2001 From: upsert <30200174+upsert@users.noreply.github.com> Date: Tue, 5 Sep 2017 05:30:36 -0400 Subject: [PATCH 118/277] Improved Lutron Caseta shade support (#9302) --- .../components/cover/lutron_caseta.py | 29 ++++++++++--------- .../components/light/lutron_caseta.py | 4 +-- homeassistant/components/lutron_caseta.py | 2 +- .../components/switch/lutron_caseta.py | 4 +-- requirements_all.txt | 2 +- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 648dba98ca6..31e4f1e3cf2 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -1,14 +1,14 @@ """ -Support for Lutron Caseta SerenaRollerShade. +Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ import logging - from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + ATTR_POSITION, DOMAIN) from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) @@ -19,11 +19,10 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Lutron Caseta Serena shades as a cover device.""" + """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - cover_devices = bridge.get_devices_by_types(["SerenaRollerShade", - "SerenaHoneycombShade"]) + cover_devices = bridge.get_devices_by_domain(DOMAIN) for cover_device in cover_devices: dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) @@ -32,7 +31,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class LutronCasetaCover(LutronCasetaDevice, CoverDevice): - """Representation of a Lutron Serena shade.""" + """Representation of a Lutron shade.""" @property def supported_features(self): @@ -42,24 +41,26 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - return self._state["current_state"] < 1 + return self._state['current_state'] < 1 @property def current_cover_position(self): """Return the current position of cover.""" - return self._state["current_state"] + return self._state['current_state'] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, position, **kwargs): - """Move the roller shutter to a specific position.""" - self._smartbridge.set_value(self._device_id, position) + def set_cover_position(self, **kwargs): + """Move the shade to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + self._smartbridge.set_value(self._device_id, position) def update(self): """Call when forcing a refresh of the device.""" diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index 8e4e9d7450e..c11b3da6f75 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/light.lutron_caseta/ import logging from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, DOMAIN) from homeassistant.components.light.lutron import ( to_hass_level, to_lutron_level) from homeassistant.components.lutron_caseta import ( @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - light_devices = bridge.get_devices_by_types(["WallDimmer", "PlugInDimmer"]) + light_devices = bridge.get_devices_by_domain(DOMAIN) for light_device in light_devices: dev = LutronCasetaLight(light_device, bridge) devs.append(dev) diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index dcb3347e919..8660546c910 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.2.7'] +REQUIREMENTS = ['pylutron-caseta==0.2.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/lutron_caseta.py b/homeassistant/components/switch/lutron_caseta.py index 585dc043315..daaba68dc5e 100644 --- a/homeassistant/components/switch/lutron_caseta.py +++ b/homeassistant/components/switch/lutron_caseta.py @@ -8,7 +8,7 @@ import logging from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchDevice, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Lutron switch.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - switch_devices = bridge.get_devices_by_type("WallSwitch") + switch_devices = bridge.get_devices_by_domain(DOMAIN) for switch_device in switch_devices: dev = LutronCasetaLight(switch_device, bridge) diff --git a/requirements_all.txt b/requirements_all.txt index 664ca45a82b..40b58ac130e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ pylitejet==0.1 pyloopenergy==0.0.17 # homeassistant.components.lutron_caseta -pylutron-caseta==0.2.7 +pylutron-caseta==0.2.8 # homeassistant.components.lutron pylutron==0.1.0 From 984cae531000df2bdfa1298f0750b27d5662fa10 Mon Sep 17 00:00:00 2001 From: Brian Hopkins Date: Tue, 5 Sep 2017 07:05:31 -0400 Subject: [PATCH 119/277] Upgrade mycroftapi to 2.0 (#9309) * updating mycroftapi version * updating mycroftapi version --- homeassistant/components/mycroft.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mycroft.py b/homeassistant/components/mycroft.py index c8179c280c8..834572bc551 100644 --- a/homeassistant/components/mycroft.py +++ b/homeassistant/components/mycroft.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['mycroftapi==0.1.2'] +REQUIREMENTS = ['mycroftapi==2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 40b58ac130e..2862aadecc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ motorparts==1.0.0 mutagen==1.38 # homeassistant.components.mycroft -mycroftapi==0.1.2 +mycroftapi==2.0 # homeassistant.components.usps myusps==1.1.3 From 5ba39c849eef9a58a1828ab1e130defb1a15e0fc Mon Sep 17 00:00:00 2001 From: Dan Sarginson Date: Tue, 5 Sep 2017 12:06:28 +0100 Subject: [PATCH 120/277] Fix for Honeywell Round thermostats (#9308) This fixes an issue (#8554) whereby the Honeywell thermostats stopped working after a period of hours or days. We do this by forgetting the authorisation token that was sent back to us when we first logged in, which causes the underlying evohomeclient library to perform the full login procedure again. --- homeassistant/components/climate/honeywell.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 4ff87aa67ab..0b2df903e17 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -196,6 +196,11 @@ class RoundThermostat(ClimateDevice): if val['id'] == self._id: data = val + except KeyError: + _LOGGER.error("Update failed from Honeywell server") + self.client.user_data = None + return + except StopIteration: _LOGGER.error("Did not receive any temperature data from the " "evohomeclient API") From a28ac37a912133e3ee31727808404af8c91c6951 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 5 Sep 2017 17:03:24 +0200 Subject: [PATCH 121/277] Update jinja to 2.9.6 (#9306) * Update jinja 2.10 * Update requirements_all.txt * Update package_constraints.txt * Update package_constraints.txt * Update requirements_all.txt * Update setup.py --- 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 932ed076d3b..43de2a54dbb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 -jinja2>=2.9.5 +jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 diff --git a/requirements_all.txt b/requirements_all.txt index 2862aadecc0..a8353b431fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3,7 +3,7 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 -jinja2>=2.9.5 +jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 diff --git a/setup.py b/setup.py index d5a6294e3d2..63f77820ca7 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ REQUIRES = [ 'pyyaml>=3.11,<4', 'pytz>=2017.02', 'pip>=8.0.3', - 'jinja2>=2.9.5', + 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.2.5', From 968ed6ef5bf2ef35fb2b4236e7f5e286889fd9e6 Mon Sep 17 00:00:00 2001 From: Sean Gollschewsky Date: Tue, 5 Sep 2017 16:11:02 +0100 Subject: [PATCH 122/277] Ensure display-name does not exceed 12 characters for CecAdapter. (#9268) * Ensure display-name does not exceed 12 characters for CecAdapter. * Miscalculated offset. --- homeassistant/components/hdmi_cec.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index 9989b2799cd..b4233f1ac82 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -31,7 +31,7 @@ DOMAIN = 'hdmi_cec' _LOGGER = logging.getLogger(__name__) -DEFAULT_DISPLAY_NAME = "HomeAssistant" +DEFAULT_DISPLAY_NAME = "HA" CONF_TYPES = 'types' ICON_UNKNOWN = 'mdi:help' @@ -181,7 +181,7 @@ def setup(hass: HomeAssistant, base_config): if host: adapter = TcpAdapter(host, name=display_name, activate_source=False) else: - adapter = CecAdapter(name=display_name, activate_source=False) + adapter = CecAdapter(name=display_name[:12], activate_source=False) hdmi_network = HDMINetwork(adapter, loop=loop) def _volume(call): From 0b1677de6d14f81ab947eed930f7aa8126dc778d Mon Sep 17 00:00:00 2001 From: Phil Cole Date: Tue, 5 Sep 2017 16:38:12 +0100 Subject: [PATCH 123/277] Expose hue group 0 (#8663) * Tado Fix #8606 Handle case where 'mode' and 'fanSpeed' are missing JSON. Based on changes in commit https://github.com/wmalgadey/tado_component/commit/adfb608f86b8bf4c1c43e71b4067cbfe1de9ba85 * Expose hue group 0 to HA #8652 If allow_hue_groups is set expose "All Hue Lights" group for "special group 0". This does add an additional Hue API call for every refresh (approx 30 secs) to get the status of the special group 0 because it's not included in the full API pull that currently occurs. * Revert "Expose hue group 0 to HA #8652" This reverts commit db7fe47ec72a4907f8a59ebfb47bc4a6dfa41e89. * Expose hue group 0 to HA #8652 If allow_hue_groups is set expose "All Hue Lights" group for "special group 0". This does add an additional Hue API call for every refresh (approx 30 secs) to get the status of the special group 0 because it's not included in the full API pull that currently occurs. * Changes per review by balloob 1) Use all_lights instead of all_lamps 2) Fix line lengths and trailing whitespace 3) Move "All Hue Lights" to GROUP_NAME_ALL_HUE_LIGHTS constant * Make "All Hue Lights" a constant * Fix trailing whitespace --- homeassistant/components/light/hue.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 746c6489c9e..79d80d2b8a0 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -83,6 +83,7 @@ SCENE_SCHEMA = vol.Schema({ }) ATTR_IS_HUE_GROUP = "is_hue_group" +GROUP_NAME_ALL_HUE_LIGHTS = "All Hue Lights" def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): @@ -203,6 +204,21 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, _LOGGER.error("Got unexpected result from Hue API") return + if not skip_groups: + # Group ID 0 is a special group in the hub for all lights, but it + # is not returned by get_api() so explicity get it and include it. + # See https://developers.meethue.com/documentation/ + # groups-api#21_get_all_groups + _LOGGER.debug("Getting group 0 from bridge") + all_lights = bridge.get_group(0) + if not isinstance(all_lights, dict): + _LOGGER.error("Got unexpected result from Hue API for group 0") + return + # Hue hub returns name of group 0 as "Group 0", so rename + # for ease of use in HA. + all_lights['name'] = GROUP_NAME_ALL_HUE_LIGHTS + api_groups["0"] = all_lights + new_lights = [] api_name = api.get('config').get('name') From 9ede0f57e601b341890fd0a92237a47f41e85b7f Mon Sep 17 00:00:00 2001 From: runningman84 Date: Tue, 5 Sep 2017 17:40:47 +0200 Subject: [PATCH 124/277] Added DWD WarnApp Sensor (#8657) * Added DWD WarnApp Sensor * Fixed some idents and spaces * Removed unused imports * Removed comment * Some fixes * Added throttle * Renamed sensor to dwd weather warnings * Renamed test file * shorten lines * shorten lines * Implemented changes requested by fabaff * added ATTRIBUTION * move ATTRIBUTION to existing method * fixed lint tests * Fix linter issues * Fix linter issues * Fix linter * Fixed linter --- .coveragerc | 1 + .../components/sensor/dwd_weather_warnings.py | 243 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 homeassistant/components/sensor/dwd_weather_warnings.py diff --git a/.coveragerc b/.coveragerc index 5e27aed0182..ecf35b8030d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -451,6 +451,7 @@ omit = homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py new file mode 100644 index 00000000000..0eeaa9424e8 --- /dev/null +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -0,0 +1,243 @@ +""" +Support for getting statistical data from a DWD Weather Warnings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dwd_weather_warnings/ + +Data is fetched from DWD: +https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html + +Warnungen vor extremem Unwetter (Stufe 4) +Unwetterwarnungen (Stufe 3) +Warnungen vor markantem Wetter (Stufe 2) +Wetterwarnungen (Stufe 1) +""" +import logging +import json +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor.rest import RestData + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by DWD" + +DEFAULT_NAME = 'DWD-Weather-Warnings' + +CONF_REGION_NAME = 'region_name' + +SCAN_INTERVAL = timedelta(minutes=15) + +MONITORED_CONDITIONS = { + 'current_warning_level': ['Current Warning Level', + None, 'mdi:close-octagon-outline'], + 'advance_warning_level': ['Advance Warning Level', + None, 'mdi:close-octagon-outline'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION_NAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DWD-Weather-Warnings sensor.""" + name = config.get(CONF_NAME) + region_name = config.get(CONF_REGION_NAME) + + api = DwdWeatherWarningsAPI(region_name) + + sensors = [DwdWeatherWarningsSensor(api, name, condition) + for condition in config[CONF_MONITORED_CONDITIONS]] + + add_devices(sensors, True) + + +class DwdWeatherWarningsSensor(Entity): + """Representation of a DWD-Weather-Warnings sensor.""" + + def __init__(self, api, name, variable): + """Initialize a DWD-Weather-Warnings sensor.""" + self._api = api + self._name = name + self._var_id = variable + + variable_info = MONITORED_CONDITIONS[variable] + self._var_name = variable_info[0] + self._var_units = variable_info[1] + self._var_icon = variable_info[2] + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(self._name, 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.""" + return self._var_units + + # pylint: disable=no-member + @property + def state(self): + """Return the state of the device.""" + try: + return round(self._api.data[self._var_id], 2) + except TypeError: + return self._api.data[self._var_id] + + # pylint: disable=no-member + @property + def device_state_attributes(self): + """Return the state attributes of the DWD-Weather-Warnings.""" + data = { + ATTR_ATTRIBUTION: ATTRIBUTION, + 'region_name': self._api.region_name + } + + if self._api.region_id is not None: + data['region_id'] = self._api.region_id + + if self._api.region_state is not None: + data['region_state'] = self._api.region_state + + if self._api.data['time'] is not None: + data['last_update'] = dt_util.as_local( + dt_util.utc_from_timestamp(self._api.data['time'] / 1000)) + + if self._var_id == 'current_warning_level': + prefix = 'current' + elif self._var_id == 'advance_warning_level': + prefix = 'advance' + else: + raise Exception('Unknown warning type') + + data['warning_count'] = self._api.data[prefix + '_warning_count'] + i = 0 + for event in self._api.data[prefix + '_warnings']: + i = i + 1 + + data['warning_{}_name'.format(i)] = event['event'] + data['warning_{}_level'.format(i)] = event['level'] + data['warning_{}_type'.format(i)] = event['type'] + if len(event['headline']) > 0: + data['warning_{}_headline'.format(i)] = event['headline'] + if len(event['description']) > 0: + data['warning_{}_description'.format(i)] = event['description'] + if len(event['instruction']) > 0: + data['warning_{}_instruction'.format(i)] = event['instruction'] + + if event['start'] is not None: + data['warning_{}_start'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['start'] / 1000)) + + if event['end'] is not None: + data['warning_{}_end'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['end'] / 1000)) + + return data + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._api.available + + def update(self): + """Get the latest data from the DWD-Weather-Warnings API.""" + self._api.update() + + +class DwdWeatherWarningsAPI(object): + """Get the latest data and update the states.""" + + def __init__(self, region_name): + """Initialize the data object.""" + resource = "{}{}{}?{}".format( + 'https://', + 'www.dwd.de', + '/DWD/warnungen/warnapp_landkreise/json/warnings.json', + 'jsonp=loadWarnings' + ) + + self._rest = RestData('GET', resource, None, None, None, True) + self.region_name = region_name + self.region_id = None + self.region_state = None + self.data = None + self.available = True + self.update() + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from the DWD-Weather-Warnings.""" + try: + self._rest.update() + + json_string = self._rest.data[24:len(self._rest.data) - 2] + json_obj = json.loads(json_string) + + data = {'time': json_obj['time']} + + for mykey, myvalue in { + 'current': 'warnings', + 'advance': 'vorabInformation' + }.items(): + + _LOGGER.debug("Found %d %s global DWD warnings", + len(json_obj[myvalue]), mykey) + + data['{}_warning_level'.format(mykey)] = 0 + my_warnings = [] + + if self.region_id is not None: + # get a specific region_id + if self.region_id in json_obj[myvalue]: + my_warnings = json_obj[myvalue][self.region_id] + + else: + # loop through all items to find warnings, region_id + # and region_state for region_name + for key in json_obj[myvalue]: + my_region = json_obj[myvalue][key][0]['regionName'] + if my_region != self.region_name: + continue + my_warnings = json_obj[myvalue][key] + my_state = json_obj[myvalue][key][0]['stateShort'] + self.region_id = key + self.region_state = my_state + break + + # Get max warning level + maxlevel = data['{}_warning_level'.format(mykey)] + for event in my_warnings: + if event['level'] >= maxlevel: + data['{}_warning_level'.format(mykey)] = event['level'] + + data['{}_warning_count'.format(mykey)] = len(my_warnings) + data['{}_warnings'.format(mykey)] = my_warnings + + _LOGGER.debug("Found %d %s local DWD warnings", + len(my_warnings), mykey) + + self.data = data + self.available = True + except TypeError: + _LOGGER.error("Unable to fetch data from DWD-Weather-Warnings") + self.available = False From 552abf7da5ca6fff526e21a31baa64a36d0578d6 Mon Sep 17 00:00:00 2001 From: BioSehnsucht Date: Tue, 5 Sep 2017 11:04:07 -0500 Subject: [PATCH 125/277] Add input_text component (#9112) --- homeassistant/components/input_text.py | 202 +++++++++++++++++++++++++ tests/components/test_input_text.py | 147 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100755 homeassistant/components/input_text.py create mode 100755 tests/components/test_input_text.py diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py new file mode 100755 index 00000000000..d17837b0ced --- /dev/null +++ b/homeassistant/components/input_text.py @@ -0,0 +1,202 @@ +""" +Component to offer a way to enter a value into a text box. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_text/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) +from homeassistant.loader import bind_hass +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_text' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_INITIAL = 'initial' +CONF_MIN = 'min' +CONF_MAX = 'max' +CONF_DISABLED = 'disabled' + +ATTR_VALUE = 'value' +ATTR_MIN = 'min' +ATTR_MAX = 'max' +ATTR_PATTERN = 'pattern' +ATTR_DISABLED = 'disabled' + +SERVICE_SELECT_VALUE = 'select_value' + +SERVICE_SELECT_VALUE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_VALUE): cv.string, +}) + + +def _cv_input_text(cfg): + """Configure validation helper for input box (voluptuous).""" + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + if minimum > maximum: + raise vol.Invalid('Max len ({}) is not greater than min len ({})' + .format(minimum, maximum)) + state = cfg.get(CONF_INITIAL) + if state is not None and (len(state) < minimum or len(state) > maximum): + raise vol.Invalid('Initial value {} length not in range {}-{}' + .format(state, minimum, maximum)) + return cfg + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MIN, default=0): vol.Coerce(int), + vol.Optional(CONF_MAX, default=100): vol.Coerce(int), + vol.Optional(CONF_INITIAL, ''): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(ATTR_PATTERN): cv.string, + vol.Optional(CONF_DISABLED, default=False): cv.boolean, + }, _cv_input_text) + }) +}, required=True, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def select_value(hass, entity_id, value): + """Set input_text to value.""" + hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + }) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up an input text box.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + initial = cfg.get(CONF_INITIAL) + icon = cfg.get(CONF_ICON) + unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + pattern = cfg.get(ATTR_PATTERN) + disabled = cfg.get(CONF_DISABLED) + + entities.append(InputText( + object_id, name, initial, minimum, maximum, icon, unit, + pattern, disabled)) + + if not entities: + return False + + @asyncio.coroutine + def async_select_value_service(call): + """Handle a calls to the input box services.""" + target_inputs = component.async_extract_from_service(call) + + tasks = [input_text.async_select_value(call.data[ATTR_VALUE]) + for input_text in target_inputs] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service, + schema=SERVICE_SELECT_VALUE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class InputText(Entity): + """Represent a text box.""" + + def __init__(self, object_id, name, initial, minimum, maximum, icon, + unit, pattern, disabled): + """Initialize a select input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._current_value = initial + self._minimum = minimum + self._maximum = maximum + self._icon = icon + self._unit = unit + self._pattern = pattern + self._disabled = disabled + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the select input box.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_value + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def disabled(self): + """Return the disabled flag.""" + return self._disabled + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_MIN: self._minimum, + ATTR_MAX: self._maximum, + ATTR_PATTERN: self._pattern, + ATTR_DISABLED: self._disabled, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + if self._current_value is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + value = state and state.state + + # Check against None because value can be 0 + if value is not None and self._minimum <= len(value) <= self._maximum: + self._current_value = value + + @asyncio.coroutine + def async_select_value(self, value): + """Select new value.""" + if len(value) < self._minimum or len(value) > self._maximum: + _LOGGER.warning("Invalid value: %s (length range %s - %s)", + value, self._minimum, self._maximum) + return + self._current_value = value + yield from self.async_update_ha_state() diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py new file mode 100755 index 00000000000..81b1f58aa87 --- /dev/null +++ b/tests/components/test_input_text.py @@ -0,0 +1,147 @@ +"""The tests for the Input text component.""" +# pylint: disable=protected-access +import asyncio +import unittest + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.input_text import (DOMAIN, select_value) + +from tests.common import get_test_home_assistant, mock_restore_cache + + +class TestInputText(unittest.TestCase): + """Test the input slider component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_1': { + 'min': 50, + 'max': 50, + }}, + ] + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_select_value(self): + """Test select_value method.""" + self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'initial': 'test', + 'min': 3, + 'max': 10, + }, + }})) + entity_id = 'input_text.test_1' + + state = self.hass.states.get(entity_id) + self.assertEqual('test', str(state.state)) + + select_value(self.hass, entity_id, 'testing') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual('testing', str(state.state)) + + select_value(self.hass, entity_id, 'testing too long') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual('testing', str(state.state)) + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_text.b1', 'test'), + State('input_text.b2', 'testing too long'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'min': 0, + 'max': 10, + }, + 'b2': { + 'min': 0, + 'max': 10, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'test' + + state = hass.states.get('input_text.b2') + assert state + assert str(state.state) == 'unknown' + + +@asyncio.coroutine +def test_initial_state_overrules_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_text.b1', 'testing'), + State('input_text.b2', 'testing too long'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'initial': 'test', + 'min': 0, + 'max': 10, + }, + 'b2': { + 'initial': 'test', + 'min': 0, + 'max': 10, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'test' + + state = hass.states.get('input_text.b2') + assert state + assert str(state.state) == 'test' + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'min': 0, + 'max': 100, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'unknown' From e4bb8b044443a38d52414eb98d45571a3677428d Mon Sep 17 00:00:00 2001 From: Jan Almeroth Date: Tue, 5 Sep 2017 18:07:58 +0200 Subject: [PATCH 126/277] Introducing a media_player component for Yamaha Multicast devices (#9258) * Introducing media_player yamaha_multicast * Fix pep8_max_line_length * Revert "Fix pep8_max_line_length" This reverts commit 664c25d6571e2f49f635aea332a848655f220c36. * Revert "Introducing media_player yamaha_multicast" This reverts commit a4fb64b53a79f68966d4af80fe9304d357bcd832. * Introducing media_player for Yamaha MultiCast Devices * Add missing Docstrings * Adding Requirements * Add Geofency device tracker (#9106) * Added Geofency device tracker Added Geofency device tracker * fix pylint error * review fixes * merge coroutines * Version bump * Version bump * D210: No whitespaces allowed surrounding docstring text * Fix linting * Version bump * Revert "Add Geofency device tracker (#9106)" This reverts commit c240d907d2f1fadecf831b3d5bb4e026ce3f892d. * Fix Invalid method names * Fix update_status timer * Fix Invalid class name "mcDevice" * Fix Access to a protected members * Introducing source_list setter * Fix logging * Version bump * D400: First line should end with a period (not 'e') * Removed unnecessary logging * Minor changes Thanks to comments from @andrey-git --- .coveragerc | 1 + .../media_player/yamaha_musiccast.py | 233 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 237 insertions(+) create mode 100644 homeassistant/components/media_player/yamaha_musiccast.py diff --git a/.coveragerc b/.coveragerc index ecf35b8030d..2fc424e91f6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -384,6 +384,7 @@ omit = homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py homeassistant/components/media_player/yamaha.py + homeassistant/components/media_player/yamaha_musiccast.py homeassistant/components/mycroft.py homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py new file mode 100644 index 00000000000..88d17b4d627 --- /dev/null +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -0,0 +1,233 @@ +"""Example for configuration.yaml. + +media_player: + - platform: yamaha_musiccast + name: "Living Room" + host: 192.168.xxx.xx + port: 5005 + +""" + +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, + STATE_UNKNOWN, STATE_ON +) +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP +) +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FEATURES = ( + SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | + SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | + SUPPORT_SELECT_SOURCE +) + +REQUIREMENTS = ['pymusiccast==0.1.0'] + +DEFAULT_NAME = "Yamaha Receiver" +DEFAULT_PORT = 5005 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Yamaha MusicCast platform.""" + import pymusiccast + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + receiver = pymusiccast.McDevice(host, udp_port=port) + _LOGGER.debug("receiver: %s / Port: %d", receiver, port) + + add_devices([YamahaDevice(receiver, name)], True) + + +class YamahaDevice(MediaPlayerDevice): + """Representation of a Yamaha MusicCast device.""" + + def __init__(self, receiver, name): + """Initialize the Yamaha MusicCast device.""" + self._receiver = receiver + self._name = name + self.power = STATE_UNKNOWN + self.volume = 0 + self.volume_max = 0 + self.mute = False + self._source = None + self._source_list = [] + self.status = STATE_UNKNOWN + self.media_status = None + self._receiver.set_yamaha_device(self) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self.power == STATE_ON and self.status is not STATE_UNKNOWN: + return self.status + return self.power + + @property + def should_poll(self): + """Push an update after each command.""" + return True + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.mute + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self.volume + + @property + def supported_features(self): + """Flag of features that are supported.""" + return SUPPORTED_FEATURES + + @property + def source(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @source_list.setter + def source_list(self, value): + """Set source_list attribute.""" + self._source_list = value + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self.media_status.media_duration \ + if self.media_status else None + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self.media_status.media_image_url \ + if self.media_status else None + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self.media_status.media_artist if self.media_status else None + + @property + def media_album(self): + """Album of current playing media, music track only.""" + return self.media_status.media_album if self.media_status else None + + @property + def media_track(self): + """Track number of current playing media, music track only.""" + return self.media_status.media_track if self.media_status else None + + @property + def media_title(self): + """Title of current playing media.""" + return self.media_status.media_title if self.media_status else None + + def update(self): + """Get the latest details from the device.""" + _LOGGER.debug("update: %s", self.entity_id) + + # call from constructor setup_platform() + if not self.entity_id: + _LOGGER.debug("First run") + self._receiver.update_status(push=False) + # call from regular polling + else: + # update_status_timer was set before + if self._receiver.update_status_timer: + _LOGGER.debug( + "is_alive: %s", + self._receiver.update_status_timer.is_alive()) + # e.g. computer was suspended, while hass was running + if not self._receiver.update_status_timer.is_alive(): + _LOGGER.debug("Reinitializing") + self._receiver.update_status() + + def turn_on(self): + """Turn on specified media player or all.""" + _LOGGER.debug("Turn device: on") + self._receiver.set_power(True) + + def turn_off(self): + """Turn off specified media player or all.""" + _LOGGER.debug("Turn device: off") + self._receiver.set_power(False) + + def media_play(self): + """Send the media player the command for play/pause.""" + _LOGGER.debug("Play") + self._receiver.set_playback("play") + + def media_pause(self): + """Send the media player the command for pause.""" + _LOGGER.debug("Pause") + self._receiver.set_playback("pause") + + def media_stop(self): + """Send the media player the stop command.""" + _LOGGER.debug("Stop") + self._receiver.set_playback("stop") + + def media_previous_track(self): + """Send the media player the command for prev track.""" + _LOGGER.debug("Previous") + self._receiver.set_playback("previous") + + def media_next_track(self): + """Send the media player the command for next track.""" + _LOGGER.debug("Next") + self._receiver.set_playback("next") + + def mute_volume(self, mute): + """Send mute command.""" + _LOGGER.debug("Mute volume: %s", mute) + self._receiver.set_mute(mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + _LOGGER.debug("Volume level: %.2f / %d", + volume, volume * self.volume_max) + self._receiver.set_volume(volume * self.volume_max) + + def select_source(self, source): + """Send the media player the command to select input source.""" + _LOGGER.debug("select_source: %s", source) + self.status = STATE_UNKNOWN + self._receiver.set_input(source) diff --git a/requirements_all.txt b/requirements_all.txt index a8353b431fd..cad3e01fd70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -660,6 +660,9 @@ pymochad==0.1.1 # homeassistant.components.modbus pymodbus==1.3.1 +# homeassistant.components.media_player.yamaha_musiccast +pymusiccast==0.1.0 + # homeassistant.components.cover.myq pymyq==0.0.8 From 418ccc820a4a4f7338d47226178afd489f827f1d Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Tue, 5 Sep 2017 18:10:01 +0200 Subject: [PATCH 127/277] Handle the case where no registration number is available (instead display VIN (vehicle identification number)). (#9073) --- .../components/device_tracker/volvooncall.py | 5 ++- homeassistant/components/volvooncall.py | 45 ++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 4312c5dd54a..7872f8f1f1c 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -20,11 +20,12 @@ def setup_scanner(hass, config, see, discovery_info=None): return vin, _ = discovery_info - vehicle = hass.data[DATA_KEY].vehicles[vin] + voc = hass.data[DATA_KEY] + vehicle = voc.vehicles[vin] def see_vehicle(vehicle): """Handle the reporting of the vehicle position.""" - host_name = vehicle.registration_number + host_name = voc.vehicle_name(vehicle) dev_id = 'volvo_{}'.format(slugify(host_name)) see(dev_id=dev_id, host_name=host_name, diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 5903bed1fc7..9c8366e7f7e 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -73,14 +73,7 @@ def setup(hass, config): interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) - class state: # pylint:disable=invalid-name - """Namespace to hold state for each vehicle.""" - - entities = {} - vehicles = {} - names = config[DOMAIN].get(CONF_NAME) - - hass.data[DATA_KEY] = state + state = hass.data[DATA_KEY] = VolvoData(config) def discover_vehicle(vehicle): """Load relevant platforms.""" @@ -120,6 +113,31 @@ def setup(hass, config): return update(utcnow()) +class VolvoData: + """Hold component state.""" + + def __init__(self, config): + """Initialize the component state.""" + self.entities = {} + self.vehicles = {} + self.names = config[DOMAIN].get(CONF_NAME) + + def vehicle_name(self, vehicle): + """Provide a friendly name for a vehicle.""" + if (vehicle.registration_number and + vehicle.registration_number.lower()) in self.names: + return self.names[vehicle.registration_number.lower()] + elif (vehicle.vin and + vehicle.vin.lower() in self.names): + return self.names[vehicle.vin.lower()] + elif vehicle.registration_number: + return vehicle.registration_number + elif vehicle.vin: + return vehicle.vin + else: + return '' + + class VolvoEntity(Entity): """Base class for all VOC entities.""" @@ -139,17 +157,14 @@ class VolvoEntity(Entity): """Return vehicle.""" return self._state.vehicles[self._vin] - @property - def _vehicle_name(self): - return (self._state.names.get(self._vin.lower()) or - self._state.names.get( - self.vehicle.registration_number.lower()) or - self.vehicle.registration_number) - @property def _entity_name(self): return RESOURCES[self._attribute][1] + @property + def _vehicle_name(self): + return self._state.vehicle_name(self.vehicle) + @property def name(self): """Return full name of the entity.""" From 788275da329c4c62c91602d4f4f9036e4fd29325 Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Tue, 5 Sep 2017 11:26:59 -0700 Subject: [PATCH 128/277] Add post_pending_state attribute to manual alarm_control_panel (#9291) Add post_pending_state attribute to manual alarm_control_panel --- .../components/alarm_control_panel/manual.py | 12 +++++++++++ .../alarm_control_panel/test_manual.py | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index d9cd6d6a9ac..f345ccc4dcd 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -24,6 +24,8 @@ DEFAULT_PENDING_TIME = 60 DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False +ATTR_POST_PENDING_STATE = 'post_pending_state' + PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, @@ -185,3 +187,13 @@ class ManualAlarm(alarm.AlarmControlPanel): if not check: _LOGGER.warning("Invalid code given for %s", state) return check + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index 328ae4acd57..063f3361148 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -72,6 +72,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_HOME)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -150,6 +155,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_AWAY)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -228,6 +238,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_NIGHT)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -314,6 +329,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_TRIGGERED)) + future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): From 9ade8002ac2fde685fe647772835621b92a36fb0 Mon Sep 17 00:00:00 2001 From: Konstantin Belyalov Date: Tue, 5 Sep 2017 16:01:03 -0700 Subject: [PATCH 129/277] Add new config variable to MQTT light (#9304) * Add new config variable to MQTT light * Address reviewer's issues: refactor template render part. * Update mqtt.py --- homeassistant/components/light/mqtt.py | 14 ++++++-- tests/components/light/test_mqtt.py | 46 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 038cacd300e..ac72a7052f1 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -39,6 +39,7 @@ CONF_EFFECT_COMMAND_TOPIC = 'effect_command_topic' CONF_EFFECT_LIST = 'effect_list' CONF_EFFECT_STATE_TOPIC = 'effect_state_topic' CONF_EFFECT_VALUE_TEMPLATE = 'effect_value_template' +CONF_RGB_COMMAND_TEMPLATE = 'rgb_command_template' CONF_RGB_COMMAND_TOPIC = 'rgb_command_topic' CONF_RGB_STATE_TOPIC = 'rgb_state_topic' CONF_RGB_VALUE_TEMPLATE = 'rgb_value_template' @@ -75,6 +76,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_RGB_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_RGB_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_RGB_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_RGB_VALUE_TEMPLATE): cv.template, @@ -125,6 +127,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE), CONF_EFFECT: config.get(CONF_EFFECT_VALUE_TEMPLATE), CONF_RGB: config.get(CONF_RGB_VALUE_TEMPLATE), + CONF_RGB_COMMAND_TEMPLATE: config.get(CONF_RGB_COMMAND_TEMPLATE), CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), CONF_WHITE_VALUE: config.get(CONF_WHITE_VALUE_TEMPLATE), CONF_XY: config.get(CONF_XY_VALUE_TEMPLATE), @@ -397,10 +400,17 @@ class MqttLight(Light): if ATTR_RGB_COLOR in kwargs and \ self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] + if tpl: + colors = {'red', 'green', 'blue'} + variables = {key: val for key, val in + zip(colors, kwargs[ATTR_RGB_COLOR])} + rgb_color_str = tpl.async_render(variables) + else: + rgb_color_str = '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]) mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]), self._qos, - self._retain) + rgb_color_str, self._qos, self._retain) if self._optimistic_rgb: self._rgb = kwargs[ATTR_RGB_COLOR] diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 97375aa6b13..e111fc3aa49 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -123,6 +123,20 @@ light: payload_on: "on" payload_off: "off" +config for RGB Version with RGB command template: + +light: + platform: mqtt + name: "Office Light RGB" + state_topic: "office/rgb1/light/status" + command_topic: "office/rgb1/light/switch" + rgb_state_topic: "office/rgb1/rgb/status" + rgb_command_topic: "office/rgb1/rgb/set" + rgb_command_template: "{{ '#%02x%02x%02x' | format(red, green, blue)}}" + qos: 0 + payload_on: "on" + payload_off: "off" + """ import unittest from unittest import mock @@ -512,6 +526,38 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(80, state.attributes['white_value']) self.assertEqual((0.123, 0.123), state.attributes['xy_color']) + def test_sending_mqtt_rgb_command_with_template(self): + """Test the sending of RGB command with template.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'rgb_command_topic': 'test_light_rgb/rgb/set', + 'rgb_command_template': '{{ "#%02x%02x%02x" | ' + 'format(red, green, blue)}}', + 'payload_on': 'on', + 'payload_off': 'off', + 'qos': 0 + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', rgb_color=[255, 255, 255]) + self.hass.block_till_done() + + self.mock_publish().async_publish.assert_has_calls([ + mock.call('test_light_rgb/set', 'on', 0, False), + mock.call('test_light_rgb/rgb/set', '#ffffff', 0, False), + ], any_order=True) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((255, 255, 255), state.attributes['rgb_color']) + def test_show_brightness_if_only_command_topic(self): """Test the brightness if only a command topic is present.""" config = {light.DOMAIN: { From e7a5f7bcdff16eca38727d690c437b6a8d8cffbc Mon Sep 17 00:00:00 2001 From: Mike Christianson Date: Tue, 5 Sep 2017 18:49:40 -0700 Subject: [PATCH 130/277] Follow Twitter guidelines for media upload by conforming to the "STATUS" phase, when required, and by providing "media_category" information. These will, for example, allow users to upload videos that exceed the basic 30 second limit. (#9261) See: - https://twittercommunity.com/t/media-category-values/64781/7 - https://twittercommunity.com/t/duration-too-long-maximim-30000/68760 - https://dev.twitter.com/rest/reference/get/media/upload-status.html --- homeassistant/components/notify/twitter.py | 111 ++++++++++++++++----- 1 file changed, 85 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 9d2a8c07932..25e6fc00a2f 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -8,6 +8,8 @@ import json import logging import mimetypes import os +from datetime import timedelta, datetime +from functools import partial import voluptuous as vol @@ -15,6 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME +from homeassistant.helpers.event import async_track_point_in_time REQUIREMENTS = ['TwitterAPI==2.4.6'] @@ -68,49 +71,67 @@ class TwitterNotificationService(BaseNotificationService): _LOGGER.warning("'%s' is not a whitelisted directory", media) return - media_id = self.upload_media(media) + callback = partial(self.send_message_callback, message) + self.upload_media_then_callback(callback, media) + + def send_message_callback(self, message, media_id): + """Tweet a message, optionally with media.""" if self.user: resp = self.api.request('direct_messages/new', - {'text': message, 'user': self.user, + {'user': self.user, + 'text': message, 'media_ids': media_id}) else: resp = self.api.request('statuses/update', - {'status': message, 'media_ids': media_id}) + {'status': message, + 'media_ids': media_id}) if resp.status_code != 200: self.log_error_resp(resp) + else: + _LOGGER.debug("Message posted: %s", resp.json()) - def upload_media(self, media_path=None): + def upload_media_then_callback(self, callback, media_path=None): """Upload media.""" if not media_path: return None + with open(media_path, 'rb') as file: + total_bytes = os.path.getsize(media_path) + (media_category, media_type) = self.media_info(media_path) + resp = self.upload_media_init( + media_type, media_category, total_bytes + ) + + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None + + media_id = resp.json()['media_id'] + media_id = self.upload_media_chunked(file, total_bytes, media_id) + + resp = self.upload_media_finalize(media_id) + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None + + self.check_status_until_done(media_id, callback) + + def media_info(self, media_path): + """Determine mime type and Twitter media category for given media.""" (media_type, _) = mimetypes.guess_type(media_path) - total_bytes = os.path.getsize(media_path) + media_category = self.media_category_for_type(media_type) + _LOGGER.debug("media %s is mime type %s and translates to %s", + media_path, media_type, media_category) + return media_category, media_type - file = open(media_path, 'rb') - resp = self.upload_media_init(media_type, total_bytes) - - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - return None - - media_id = resp.json()['media_id'] - media_id = self.upload_media_chunked(file, total_bytes, media_id) - - resp = self.upload_media_finalize(media_id) - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - - return media_id - - def upload_media_init(self, media_type, total_bytes): + def upload_media_init(self, media_type, media_category, total_bytes): """Upload media, INIT phase.""" - resp = self.api.request('media/upload', + return self.api.request('media/upload', {'command': 'INIT', 'media_type': media_type, + 'media_category': media_category, 'total_bytes': total_bytes}) - return resp def upload_media_chunked(self, file, total_bytes, media_id): """Upload media, chunked append.""" @@ -128,17 +149,55 @@ class TwitterNotificationService(BaseNotificationService): return media_id def upload_media_append(self, chunk, media_id, segment_id): - """Upload media, append phase.""" + """Upload media, APPEND phase.""" return self.api.request('media/upload', {'command': 'APPEND', 'media_id': media_id, 'segment_index': segment_id}, {'media': chunk}) def upload_media_finalize(self, media_id): - """Upload media, finalize phase.""" + """Upload media, FINALIZE phase.""" return self.api.request('media/upload', {'command': 'FINALIZE', 'media_id': media_id}) + def check_status_until_done(self, media_id, callback, *args): + """Upload media, STATUS phase.""" + resp = self.api.request('media/upload', + {'command': 'STATUS', 'media_id': media_id}, + method_override='GET') + if resp.status_code != 200: + _LOGGER.error("media processing error: %s", resp.json()) + processing_info = resp.json()['processing_info'] + + _LOGGER.debug("media processing %s status: %s", media_id, + processing_info) + + if processing_info['state'] in {u'succeeded', u'failed'}: + return callback(media_id) + + check_after_secs = processing_info['check_after_secs'] + _LOGGER.debug("media processing waiting %s seconds to check status", + str(check_after_secs)) + + when = datetime.now() + timedelta(seconds=check_after_secs) + myself = partial(self.check_status_until_done, media_id, callback) + async_track_point_in_time(self.hass, myself, when) + + @staticmethod + def media_category_for_type(media_type): + """Determine Twitter media category by mime type.""" + if media_type is None: + return None + + if media_type.startswith('image/gif'): + return 'tweet_gif' + elif media_type.startswith('video/'): + return 'tweet_video' + elif media_type.startswith('image/'): + return 'tweet_image' + + return None + @staticmethod def log_bytes_sent(bytes_sent, total_bytes): """Log upload progress.""" From 5971a7c009b4b7f742ccd2e16d7f1699c7d951b0 Mon Sep 17 00:00:00 2001 From: ohmer1 <1868995+ohmer1@users.noreply.github.com> Date: Wed, 6 Sep 2017 01:58:13 -0400 Subject: [PATCH 131/277] Optionally disable ssl certificate validity check. (#9181) * Optionally disable ssl certificate validity check. * Fix lines too long. * Fix formatting. * Force build CI * Fix "Method could be a function (no-self-use)" --- homeassistant/components/notify/xmpp.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 42c7a3953b9..f93e1b8f426 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -21,12 +21,14 @@ REQUIREMENTS = ['sleekxmpp==1.3.2', _LOGGER = logging.getLogger(__name__) CONF_TLS = 'tls' +CONF_VERIFY = 'verify' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, + vol.Optional(CONF_VERIFY, default=True): cv.boolean, }) @@ -34,18 +36,20 @@ def get_service(hass, config, discovery_info=None): """Get the Jabber (XMPP) notification service.""" return XmppNotificationService( config.get(CONF_SENDER), config.get(CONF_PASSWORD), - config.get(CONF_RECIPIENT), config.get(CONF_TLS)) + config.get(CONF_RECIPIENT), config.get(CONF_TLS), + config.get(CONF_VERIFY)) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, password, recipient, tls): + def __init__(self, sender, password, recipient, tls, verify): """Initialize the service.""" self._sender = sender self._password = password self._recipient = recipient self._tls = tls + self._verify = verify def send_message(self, message="", **kwargs): """Send a message to a user.""" @@ -53,10 +57,11 @@ class XmppNotificationService(BaseNotificationService): data = '{}: {}'.format(title, message) if title else message send_message('{}/home-assistant'.format(self._sender), self._password, - self._recipient, self._tls, data) + self._recipient, self._tls, self._verify, data) -def send_message(sender, password, recipient, use_tls, message): +def send_message(sender, password, recipient, use_tls, + verify_certificate, message): """Send a message over XMPP.""" import sleekxmpp @@ -73,6 +78,10 @@ def send_message(sender, password, recipient, use_tls, message): self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) self.add_event_handler('session_start', self.start) + if not verify_certificate: + self.add_event_handler('ssl_invalid_cert', + self.discard_ssl_invalid_cert) + self.connect(use_tls=self.use_tls, use_ssl=False) self.process() @@ -87,4 +96,10 @@ def send_message(sender, password, recipient, use_tls, message): """Disconnect from the server if credentials are invalid.""" self.disconnect() + @staticmethod + def discard_ssl_invalid_cert(event): + """Do nothing if ssl certificate is invalid.""" + _LOGGER.info('Ignoring invalid ssl certificate as requested.') + return + SendNotificationBot() From fad914de8c5cae24288513b7c596625a49ed7df3 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 6 Sep 2017 20:05:34 +0530 Subject: [PATCH 132/277] Version bump dlib to 1.0.0 (#9316) --- homeassistant/components/image_processing/dlib_face_detect.py | 2 +- homeassistant/components/image_processing/dlib_face_identify.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/image_processing/dlib_face_detect.py b/homeassistant/components/image_processing/dlib_face_detect.py index 6de85f022f3..65705feb7f7 100644 --- a/homeassistant/components/image_processing/dlib_face_detect.py +++ b/homeassistant/components/image_processing/dlib_face_detect.py @@ -15,7 +15,7 @@ from homeassistant.components.image_processing import ( from homeassistant.components.image_processing.microsoft_face_identify import ( ImageProcessingFaceEntity) -REQUIREMENTS = ['face_recognition==0.2.2'] +REQUIREMENTS = ['face_recognition==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/dlib_face_identify.py b/homeassistant/components/image_processing/dlib_face_identify.py index 50a7bc846c4..22594aa2547 100644 --- a/homeassistant/components/image_processing/dlib_face_identify.py +++ b/homeassistant/components/image_processing/dlib_face_identify.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing.microsoft_face_identify import ( ImageProcessingFaceEntity) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['face_recognition==0.2.2'] +REQUIREMENTS = ['face_recognition==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cad3e01fd70..9114d774234 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ evohomeclient==0.2.5 # homeassistant.components.image_processing.dlib_face_detect # homeassistant.components.image_processing.dlib_face_identify -# face_recognition==0.2.2 +# face_recognition==1.0.0 # homeassistant.components.sensor.fastdotcom fastdotcom==0.0.1 From 894200d87dc0502864c869453ab2769350ef519c Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Wed, 6 Sep 2017 09:11:32 -0700 Subject: [PATCH 133/277] Fixed bug with devices not being discovered correctly. (#9311) --- homeassistant/components/abode.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index c8d4ee67d49..f3283eff748 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -52,7 +52,8 @@ def setup(hass, config): password = conf.get(CONF_PASSWORD) try: - hass.data[DATA_ABODE] = abode = abodepy.Abode(username, password) + hass.data[DATA_ABODE] = abode = abodepy.Abode( + username, password, auto_login=True, get_devices=True) except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) From 9a7089bad30f3fe0b8a05569f723e9ce77d40a60 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 7 Sep 2017 09:01:59 +0200 Subject: [PATCH 134/277] Platform not ready behavior fixed. (#9325) Expose the device model as sensor attribute. Device initialized log message added. Provides device model, firmware and hardware version. --- .../components/light/xiaomi_philipslight.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_philipslight.py index 96d2d7ff9d2..8df25153a73 100644 --- a/homeassistant/components/light/xiaomi_philipslight.py +++ b/homeassistant/components/light/xiaomi_philipslight.py @@ -35,6 +35,7 @@ CCT_MIN = 1 CCT_MAX = 100 SUCCESS = ['ok'] +ATTR_MODEL = 'model' # pylint: disable=unused-argument @@ -53,8 +54,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): try: light = Ceil(host, token) + device_info = light.info() + _LOGGER.info("%s %s %s initialized", + device_info.raw['model'], + device_info.raw['fw_ver'], + device_info.raw['hw_ver']) - philips_light = XiaomiPhilipsLight(name, light) + philips_light = XiaomiPhilipsLight(name, light, device_info) hass.data[PLATFORM][host] = philips_light except DeviceException: raise PlatformNotReady @@ -65,15 +71,19 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class XiaomiPhilipsLight(Light): """Representation of a Xiaomi Philips Light.""" - def __init__(self, name, light): + def __init__(self, name, light, device_info): """Initialize the light device.""" self._name = name + self._device_info = device_info self._brightness = None self._color_temp = None self._light = light self._state = None + self._state_attrs = { + ATTR_MODEL: self._device_info.raw['model'], + } @property def should_poll(self): @@ -90,6 +100,11 @@ class XiaomiPhilipsLight(Light): """Return true when state is known.""" return self._state is not None + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + @property def is_on(self): """Return true if light is on.""" From 77d0ad1797e39ac694576a2de779dd4eadebe305 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Thu, 7 Sep 2017 09:11:55 +0200 Subject: [PATCH 135/277] Stable and asynchronous KNX library. (#8725) * First draft of XKNX module for Home-Assistant * XKNX does now take path of xknx.yaml as parameter * small fix, telegram_received_callback has different signature * changed method of registering callbacks of devices * removed non async command lines from xknx * telegram_received_cb not needed within HASS module * updated requirements * Configuration if XKNX should connect via Routing or Tunneling * bumping version to 0.6.1 * small fix within xknx plugin * bumped version * XKNX-Switches are now BinarySensors and Logic from Sensor was moved to BinarySensor * renamed Outlet to Switch * pylint * configuration of KNX lights via HASS config, yay! * changed name of attribute * Added configuration for xknx to switch component * added support for sensors within hass configuration * added support for climate within hass configuration * Thermostat -> Climate * added configuration support for binary_sensors * renamed Shutter to Cover * added configuration support for cover * restructured file structure according to HASS requirements * pylint * pylint * pylint * pylint * pylint * pylint * updated version * pylint * pylint * pylint * added setpoint support for climate devices * devices are now in a different module * more asyncio :-) * pydocstyle * pydocstyle * added actions to binary_sensor * allow more than one automation * readded requirement * Modifications suggested by hound * Modifications suggested by hound * Modifications suggested by hound * Modifications suggested by hound * xknx now imported as local import * hound *sigh* * lint * 'fixed' coverage. * next try for getting gen_requirements_all.py working * removed blank line * XKNX 0.7.1 with logging functionality, replaced some print() calls with _LOGGER * updated requirements_all.txt * Fixes issue https://github.com/XKNX/xknx/issues/51 * https://github.com/XKNX/xknx/issues/52 added raw access to KNX bus from HASS component. * bumped version - 0.7.3 contains some bugfixes * bumped version - 0.7.3 contains some bugfixes * setting setpoint within climate device has to be async * bumped version to 0.7.4 * bumped version * https://github.com/XKNX/xknx/issues/48 Adding HVAC support. * pylint suggestions * Made target temperature and set point required attributes * renamed value_type to type within sensor configuration * Issue https://github.com/XKNX/xknx/issues/52 : added filter functionality for not flooding the event bus. * suggestions by pylint * Added notify support for knx platform. * logging error if discovery_info is None. * review suggestions by @armills * line too long * Using discovery_info to notifiy component which devices should be added. * moved XKNX automation to main level. * renamed xknx component to knx. * reverted change within .coveragerc * changed dependency * updated docstrings. * updated version of xknx within requirements_all.txt * moved requirement to correct position * renamed configuration attribute * added @callback-decorator and async_prefix. * added @callback decorator and async_ prefix to register_callbacks functions * fixed typo * pylint suggestions * added angle position and invert_position and invert_angle to cover.knx * typo * bumped version within requirements_all.txt * bumped version * Added support for HVAC controller status --- homeassistant/components/binary_sensor/knx.py | 142 +++- homeassistant/components/climate/knx.py | 182 +++-- homeassistant/components/cover/knx.py | 320 +++++---- homeassistant/components/knx.py | 642 ++++++------------ homeassistant/components/light/knx.py | 192 ++++-- homeassistant/components/notify/__init__.py | 13 +- homeassistant/components/notify/knx.py | 99 +++ homeassistant/components/sensor/knx.py | 227 +++---- homeassistant/components/switch/knx.py | 105 ++- requirements_all.txt | 6 +- 10 files changed, 1041 insertions(+), 887 deletions(-) create mode 100644 homeassistant/components/notify/knx.py diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 87f8a30d78c..2b11c3fe172 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -1,21 +1,145 @@ """ -Contains functionality to use a KNX group address as a binary. +Support for KNX/IP binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +import asyncio +import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES, \ + KNXAutomation +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, \ + BinarySensorDevice +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_DEVICE_CLASS = 'device_class' +CONF_SIGNIFICANT_BIT = 'significant_bit' +CONF_DEFAULT_SIGNIFICANT_BIT = 1 +CONF_AUTOMATION = 'automation' +CONF_HOOK = 'hook' +CONF_DEFAULT_HOOK = 'on' +CONF_COUNTER = 'counter' +CONF_DEFAULT_COUNTER = 1 +CONF_ACTION = 'action' + +CONF__ACTION = 'turn_off_action' + +DEFAULT_NAME = 'KNX Binary Sensor' DEPENDENCIES = ['knx'] +AUTOMATION_SCHEMA = vol.Schema({ + vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, + vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, + vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA +}) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX binary sensor platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +AUTOMATIONS_SCHEMA = vol.All( + cv.ensure_list, + [AUTOMATION_SCHEMA] +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): + cv.positive_int, + vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, +}) -class KNXSwitch(KNXGroupAddress, BinarySensorDevice): - """Representation of a KNX binary sensor device.""" +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up binary sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False - pass + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up binary sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXBinarySensor(hass, device)) + add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up binary senor for KNX platform configured within plattform.""" + name = config.get(CONF_NAME) + import xknx + binary_sensor = xknx.devices.BinarySensor( + hass.data[DATA_KNX].xknx, + name=name, + group_address=config.get(CONF_ADDRESS), + device_class=config.get(CONF_DEVICE_CLASS), + significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + hass.data[DATA_KNX].xknx.devices.add(binary_sensor) + + entity = KNXBinarySensor(hass, binary_sensor) + automations = config.get(CONF_AUTOMATION) + if automations is not None: + for automation in automations: + counter = automation.get(CONF_COUNTER) + hook = automation.get(CONF_HOOK) + action = automation.get(CONF_ACTION) + entity.automations.append(KNXAutomation( + hass=hass, device=binary_sensor, hook=hook, + action=action, counter=counter)) + add_devices([entity]) + + +class KNXBinarySensor(BinarySensorDevice): + """Representation of a KNX binary sensor.""" + + def __init__(self, hass, device): + """Initialization of KNXBinarySensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + self.automations = [] + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def device_class(self): + """Return the class of this sensor.""" + return self.device.device_class + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.device.is_on() diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index e399e2f3dca..688ded5e7c4 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -1,68 +1,136 @@ """ -Support for KNX thermostats. +Support for KNX/IP climate devices. -For more details about this platform, please refer to the documentation +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import logging - +import asyncio import voluptuous as vol -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_ADDRESS = 'address' CONF_SETPOINT_ADDRESS = 'setpoint_address' CONF_TEMPERATURE_ADDRESS = 'temperature_address' +CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' +CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' +CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' +CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' +CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address' +CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \ + 'operation_mode_frost_protection_address' +CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' +CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' -DEFAULT_NAME = 'KNX Thermostat' +DEFAULT_NAME = 'KNX Climate' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXThermostat(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up climate(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): - """Representation of a KNX thermostat. +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up climates for KNX platform configured within plattform.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXClimate(hass, device)) + add_devices(entities) - A KNX thermostat will has the following parameters: - - temperature (current temperature) - - setpoint (target temperature in HASS terms) - - operation mode selection (comfort/night/frost protection) - This version supports only polling. Messages from the KNX bus do not - automatically update the state of the thermostat (to be implemented - in future releases) - """ +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up climate for KNX platform configured within plattform.""" + import xknx + climate = xknx.devices.Climate( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_temperature=config.get( + CONF_TEMPERATURE_ADDRESS), + group_address_target_temperature=config.get( + CONF_TARGET_TEMPERATURE_ADDRESS), + group_address_setpoint=config.get( + CONF_SETPOINT_ADDRESS), + group_address_operation_mode=config.get( + CONF_OPERATION_MODE_ADDRESS), + group_address_operation_mode_state=config.get( + CONF_OPERATION_MODE_STATE_ADDRESS), + group_address_controller_status=config.get( + CONF_CONTROLLER_STATUS_ADDRESS), + group_address_controller_status_state=config.get( + CONF_CONTROLLER_STATUS_STATE_ADDRESS), + group_address_operation_mode_protection=config.get( + CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS), + group_address_operation_mode_night=config.get( + CONF_OPERATION_MODE_NIGHT_ADDRESS), + group_address_operation_mode_comfort=config.get( + CONF_OPERATION_MODE_COMFORT_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(climate) + add_devices([KNXClimate(hass, climate)]) - def __init__(self, hass, config): - """Initialize the thermostat based on the given configuration.""" - KNXMultiAddressDevice.__init__( - self, hass, config, ['temperature', 'setpoint'], ['mode']) - self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius +class KNXClimate(ClimateDevice): + """Representation of a KNX climate.""" + + def __init__(self, hass, device): + """Initialization of KNXClimate.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + self._unit_of_measurement = TEMP_CELSIUS self._away = False # not yet supported self._is_fan_on = False # not yet supported - self._current_temp = None - self._target_temp = None + + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Return the polling state, is needed for the KNX thermostat.""" - return True + """No polling needed within KNX.""" + return False @property def temperature_unit(self): @@ -72,32 +140,42 @@ class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._current_temp + return self.device.temperature @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + if self.device.supports_target_temperature: + return self.device.target_temperature + return None - def set_temperature(self, **kwargs): + @asyncio.coroutine + def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - from knxip.conversion import float_to_knx2 + if self.device.supports_target_temperature: + yield from self.device.set_target_temperature(temperature) - self.set_value('setpoint', float_to_knx2(temperature)) - _LOGGER.debug("Set target temperature to %s", temperature) + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.device.supports_operation_mode: + return self.device.operation_mode.value + return None - def set_operation_mode(self, operation_mode): + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [operation_mode.value for + operation_mode in + self.device.get_supported_operation_modes()] + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode): """Set operation mode.""" - raise NotImplementedError() - - def update(self): - """Update KNX climate.""" - from knxip.conversion import knx2_to_float - - super().update() - - self._current_temp = knx2_to_float(self.value('temperature')) - self._target_temp = knx2_to_float(self.value('setpoint')) + if self.device.supports_operation_mode: + from xknx.knx import HVACOperationMode + knx_operation_mode = HVACOperationMode(operation_mode) + yield from self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 4883cfe3648..e4c2931983d 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -1,185 +1,239 @@ """ -Support for KNX covers. +Support for KNX/IP covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import logging - +import asyncio import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, ATTR_POSITION, DEVICE_CLASSES_SCHEMA, - SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, SUPPORT_STOP, - SUPPORT_SET_TILT_POSITION -) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, CONF_DEVICE_CLASS) + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, + SUPPORT_SET_POSITION, SUPPORT_STOP, SUPPORT_SET_TILT_POSITION, + ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.core import callback +from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_GETPOSITION_ADDRESS = 'getposition_address' -CONF_SETPOSITION_ADDRESS = 'setposition_address' -CONF_GETANGLE_ADDRESS = 'getangle_address' -CONF_SETANGLE_ADDRESS = 'setangle_address' -CONF_STOP = 'stop_address' -CONF_UPDOWN = 'updown_address' +CONF_MOVE_LONG_ADDRESS = 'move_long_address' +CONF_MOVE_SHORT_ADDRESS = 'move_short_address' +CONF_POSITION_ADDRESS = 'position_address' +CONF_POSITION_STATE_ADDRESS = 'position_state_address' +CONF_ANGLE_ADDRESS = 'angle_address' +CONF_ANGLE_STATE_ADDRESS = 'angle_state_address' +CONF_TRAVELLING_TIME_DOWN = 'travelling_time_down' +CONF_TRAVELLING_TIME_UP = 'travelling_time_up' CONF_INVERT_POSITION = 'invert_position' CONF_INVERT_ANGLE = 'invert_angle' +DEFAULT_TRAVEL_TIME = 25 DEFAULT_NAME = 'KNX Cover' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_UPDOWN): cv.string, - vol.Required(CONF_STOP): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_GETPOSITION_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SETPOSITION_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, + vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Inclusive(CONF_GETANGLE_ADDRESS, 'angle'): cv.string, - vol.Inclusive(CONF_SETANGLE_ADDRESS, 'angle'): cv.string, vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXCover(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up cover(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXCover(KNXMultiAddressDevice, CoverDevice): - """Representation of a KNX cover. e.g. a rollershutter.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up covers for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXCover(hass, device)) + add_devices(entities) - def __init__(self, hass, config): + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up cover for KNX platform configured within plattform.""" + import xknx + cover = xknx.devices.Cover( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_long=config.get(CONF_MOVE_LONG_ADDRESS), + group_address_short=config.get(CONF_MOVE_SHORT_ADDRESS), + group_address_position_state=config.get( + CONF_POSITION_STATE_ADDRESS), + group_address_angle=config.get(CONF_ANGLE_ADDRESS), + group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), + group_address_position=config.get(CONF_POSITION_ADDRESS), + travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP)) + + invert_position = config.get(CONF_INVERT_POSITION) + invert_angle = config.get(CONF_INVERT_ANGLE) + hass.data[DATA_KNX].xknx.devices.add(cover) + add_devices([KNXCover(hass, cover, invert_position, invert_angle)]) + + +class KNXCover(CoverDevice): + """Representation of a KNX cover.""" + + def __init__(self, hass, device, invert_position=False, + invert_angle=False): """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - ['updown', 'stop'], # required - optional=['setposition', 'getposition', - 'getangle', 'setangle'] - ) - self._device_class = config.config.get(CONF_DEVICE_CLASS) - self._invert_position = config.config.get(CONF_INVERT_POSITION) - self._invert_angle = config.config.get(CONF_INVERT_ANGLE) - self._hass = hass - self._current_pos = None - self._target_pos = None - self._current_tilt = None - self._target_tilt = None - self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ - SUPPORT_SET_POSITION | SUPPORT_STOP + self.device = device + self.invert_position = invert_position + self.invert_angle = invert_angle + self.hass = hass + self.async_register_callbacks() - # Tilt is only supported, if there is a angle get and set address - if CONF_SETANGLE_ADDRESS in config.config: - _LOGGER.debug("%s: Tilt supported at addresses %s, %s", - self.name, config.config.get(CONF_SETANGLE_ADDRESS), - config.config.get(CONF_GETANGLE_ADDRESS)) - self._supported_features = self._supported_features | \ - SUPPORT_SET_TILT_POSITION + self._unsubscribe_auto_updater = None + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Polling is needed for the KNX cover.""" - return True + """No polling needed within KNX.""" + return False @property def supported_features(self): """Flag supported features.""" - return self._supported_features + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ + SUPPORT_SET_POSITION | SUPPORT_STOP + if self.device.supports_angle: + supported_features |= SUPPORT_SET_TILT_POSITION + return supported_features + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return int(self.from_knx_position( + self.device.current_position(), + self.invert_position)) @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - if self.current_cover_position > 0: - return False - else: - return True + return self.device.is_closed() - @property - def current_cover_position(self): - """Return current position of cover. + @asyncio.coroutine + def async_close_cover(self, **kwargs): + """Close the cover.""" + if not self.device.is_closed(): + yield from self.device.set_down() + self.start_auto_updater() - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_pos + @asyncio.coroutine + def async_open_cover(self, **kwargs): + """Open the cover.""" + if not self.device.is_open(): + yield from self.device.set_up() + self.start_auto_updater() - @property - def target_position(self): - """Return the position we are trying to reach: 0 - 100.""" - return self._target_pos + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + knx_position = self.to_knx_position(position, self.invert_position) + yield from self.device.set_position(knx_position) + self.start_auto_updater() + + @asyncio.coroutine + def async_stop_cover(self, **kwargs): + """Stop the cover.""" + yield from self.device.stop() + self.stop_auto_updater() @property def current_cover_tilt_position(self): - """Return current position of cover. + """Return current tilt position of cover.""" + if not self.device.supports_angle: + return None + return int(self.from_knx_position( + self.device.angle, + self.invert_angle)) - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_tilt + @asyncio.coroutine + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if ATTR_TILT_POSITION in kwargs: + position = kwargs[ATTR_TILT_POSITION] + knx_position = self.to_knx_position(position, self.invert_angle) + yield from self.device.set_angle(knx_position) - @property - def target_tilt(self): - """Return the tilt angle (in %) we are trying to reach: 0 - 100.""" - return self._target_tilt + def start_auto_updater(self): + """Start the autoupdater to update HASS while cover is moving.""" + if self._unsubscribe_auto_updater is None: + self._unsubscribe_auto_updater = async_track_utc_time_change( + self.hass, self.auto_updater_hook) - def set_cover_position(self, **kwargs): - """Set new target position.""" - position = kwargs.get(ATTR_POSITION) - if position is None: - return + def stop_auto_updater(self): + """Stop the autoupdater.""" + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + self._unsubscribe_auto_updater = None - if self._invert_position: - position = 100-position + @callback + def auto_updater_hook(self, now): + """Callback for autoupdater.""" + # pylint: disable=unused-argument + self.hass.async_add_job(self.async_update_ha_state()) + if self.device.position_reached(): + self.stop_auto_updater() - self._target_pos = position - self.set_percentage('setposition', position) - _LOGGER.debug("%s: Set target position to %d", self.name, position) + self.hass.add_job(self.device.auto_stop_if_necessary()) - def update(self): - """Update device state.""" - super().update() - value = self.get_percentage('getposition') - if value is not None: - self._current_pos = value - if self._invert_position: - self._current_pos = 100-value - _LOGGER.debug("%s: position = %d", self.name, value) + @staticmethod + def from_knx_position(raw, invert): + """Convert KNX position [0...255] to hass position [100...0].""" + position = round((raw/256)*100) + if not invert: + position = 100 - position + return position - if self._supported_features & SUPPORT_SET_TILT_POSITION: - value = self.get_percentage('getangle') - if value is not None: - self._current_tilt = value - if self._invert_angle: - self._current_tilt = 100-value - _LOGGER.debug("%s: tilt = %d", self.name, value) - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("%s: open: updown = 0", self.name) - self.set_int_value('updown', 0) - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("%s: open: updown = 1", self.name) - self.set_int_value('updown', 1) - - def stop_cover(self, **kwargs): - """Stop the cover movement.""" - _LOGGER.debug("%s: stop: stop = 1", self.name) - self.set_int_value('stop', 1) - - def set_cover_tilt_position(self, tilt_position, **kwargs): - """Move the cover til to a specific position.""" - if self._invert_angle: - tilt_position = 100-tilt_position - - self._target_tilt = round(tilt_position, -1) - self.set_percentage('setangle', tilt_position) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class + @staticmethod + def to_knx_position(value, invert): + """Convert hass position [100...0] to KNX position [0...255].""" + knx_position = round(value/100*255.4) + if not invert: + knx_position = 255-knx_position + print(value, " -> ", knx_position) + return knx_position diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 9530becb6ce..a5015ff9454 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -1,495 +1,255 @@ """ -Support for KNX components. -For more details about this component, please refer to the documentation at +Connects to KNX platform. + +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ + """ import logging -import os +import asyncio import voluptuous as vol +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) -from homeassistant.helpers.entity import Entity -from homeassistant.config import load_yaml_config_file +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, \ + CONF_HOST, CONF_PORT +from homeassistant.helpers.script import Script -REQUIREMENTS = ['knxip==0.5'] +DOMAIN = "knx" +DATA_KNX = "data_knx" +CONF_KNX_CONFIG = "config_file" + +CONF_KNX_ROUTING = "routing" +CONF_KNX_TUNNELING = "tunneling" +CONF_KNX_LOCAL_IP = "local_ip" +CONF_KNX_FIRE_EVENT = "fire_event" +CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" + +SERVICE_KNX_SEND = "send" +SERVICE_KNX_ATTR_ADDRESS = "address" +SERVICE_KNX_ATTR_PAYLOAD = "payload" + +ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = '0.0.0.0' -DEFAULT_PORT = 3671 -DOMAIN = 'knx' +REQUIREMENTS = ['xknx==0.7.13'] -EVENT_KNX_FRAME_RECEIVED = 'knx_frame_received' -EVENT_KNX_FRAME_SEND = 'knx_frame_send' +TUNNELING_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) -KNXTUNNEL = None -KNX_ADDRESS = "address" -KNX_DATA = "data" -KNX_GROUP_WRITE = "group_write" -CONF_LISTEN = "listen" +ROUTING_SCHEMA = vol.Schema({ + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_LISTEN, default=[]): - vol.All(cv.ensure_list, [cv.string]), - }), + vol.Optional(CONF_KNX_CONFIG): cv.string, + vol.Exclusive(CONF_KNX_ROUTING, 'connection_type'): ROUTING_SCHEMA, + vol.Exclusive(CONF_KNX_TUNNELING, 'connection_type'): + TUNNELING_SCHEMA, + vol.Inclusive(CONF_KNX_FIRE_EVENT, 'fire_ev'): + cv.boolean, + vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): + vol.All( + cv.ensure_list, + [cv.string]) + }) }, extra=vol.ALLOW_EXTRA) -KNX_WRITE_SCHEMA = vol.Schema({ - vol.Required(KNX_ADDRESS): vol.All(cv.ensure_list, [cv.string]), - vol.Required(KNX_DATA): vol.All(cv.ensure_list, [cv.byte]) +SERVICE_KNX_SEND_SCHEMA = vol.Schema({ + vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string, + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( + cv.positive_int, [cv.positive_int]), }) -def setup(hass, config): - """Set up the connection to the KNX IP interface.""" - global KNXTUNNEL - - from knxip.ip import KNXIPTunnel - from knxip.core import KNXException, parse_group_address - - host = config[DOMAIN].get(CONF_HOST) - port = config[DOMAIN].get(CONF_PORT) - - if host == '0.0.0.0': - _LOGGER.debug("Will try to auto-detect KNX/IP gateway") - - KNXTUNNEL = KNXIPTunnel(host, port) +@asyncio.coroutine +def async_setup(hass, config): + """Set up knx component.""" + from xknx.exceptions import XKNXException try: - res = KNXTUNNEL.connect() - _LOGGER.debug("Res = %s", res) - if not res: - _LOGGER.error("Could not connect to KNX/IP interface %s", host) - return False + hass.data[DATA_KNX] = KNXModule(hass, config) + yield from hass.data[DATA_KNX].start() - except KNXException as ex: - _LOGGER.exception("Can't connect to KNX/IP interface: %s", ex) - KNXTUNNEL = None + except XKNXException as ex: + _LOGGER.exception("Can't connect to KNX interface: %s", ex) return False - _LOGGER.info("KNX IP tunnel to %s:%i established", host, port) + for component, discovery_type in ( + ('switch', 'Switch'), + ('climate', 'Climate'), + ('cover', 'Cover'), + ('light', 'Light'), + ('sensor', 'Sensor'), + ('binary_sensor', 'BinarySensor'), + ('notify', 'Notification')): + found_devices = _get_devices(hass, discovery_type) + hass.async_add_job( + discovery.async_load_platform(hass, component, DOMAIN, { + ATTR_DISCOVER_DEVICES: found_devices + }, config)) - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - - def received_knx_event(address, data): - """Process received KNX message.""" - if len(data) == 1: - data = data[0] - hass.bus.fire('knx_event', { - 'address': address, - 'data': data - }) - - for listen in config[DOMAIN].get(CONF_LISTEN): - _LOGGER.debug("Registering listener for %s", listen) - try: - KNXTUNNEL.register_listener(parse_group_address(listen), - received_knx_event) - except KNXException as knxexception: - _LOGGER.error("Can't register KNX listener for address %s (%s)", - listen, knxexception) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_tunnel) - - # Listen to KNX events and send them to the bus - def handle_group_write(call): - """Bridge knx_frame_send events to the KNX bus.""" - # parameters are pre-validated using KNX_WRITE_SCHEMA - addrlist = call.data.get("address") - knxdata = call.data.get("data") - - knxaddrlist = [] - for addr in addrlist: - try: - _LOGGER.debug("Found %s", addr) - knxaddr = int(addr) - except ValueError: - knxaddr = None - - if knxaddr is None: - try: - knxaddr = parse_group_address(addr) - except KNXException: - _LOGGER.error("KNX address format incorrect: %s", addr) - - knxaddrlist.append(knxaddr) - - for addr in knxaddrlist: - KNXTUNNEL.group_write(addr, knxdata) - - # Listen for when knx_frame_send event is fired - hass.services.register(DOMAIN, - KNX_GROUP_WRITE, - handle_group_write, - descriptions[DOMAIN][KNX_GROUP_WRITE], - schema=KNX_WRITE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_KNX_SEND, + hass.data[DATA_KNX].service_send_to_knx_bus, + schema=SERVICE_KNX_SEND_SCHEMA) return True -def close_tunnel(_data): - """Close the NKX tunnel connection on shutdown.""" - global KNXTUNNEL - - KNXTUNNEL.disconnect() - KNXTUNNEL = None +def _get_devices(hass, discovery_type): + return list( + map(lambda device: device.name, + filter( + lambda device: type(device).__name__ == discovery_type, + hass.data[DATA_KNX].xknx.devices))) -class KNXConfig(object): - """Handle the fetching of configuration from the config file.""" - - def __init__(self, config): - """Initialize the configuration.""" - from knxip.core import parse_group_address - - self.config = config - self.should_poll = config.get('poll', True) - if config.get('address'): - self._address = parse_group_address(config.get('address')) - else: - self._address = None - if self.config.get('state_address'): - self._state_address = parse_group_address( - self.config.get('state_address')) - else: - self._state_address = None - - @property - def name(self): - """Return the name given to the entity.""" - return self.config['name'] - - @property - def address(self): - """Return the address of the device as an integer value. - - 3 types of addresses are supported: - integer - 0-65535 - 2 level - a/b - 3 level - a/b/c - """ - return self._address - - @property - def state_address(self): - """Return the group address the device sends its current state to. - - Some KNX devices can send the current state to a seperate - group address. This makes send e.g. when an actuator can - be switched but also have a timer functionality. - """ - return self._state_address - - -class KNXGroupAddress(Entity): - """Representation of devices connected to a KNX group address.""" +class KNXModule(object): + """Representation of KNX Object.""" def __init__(self, hass, config): - """Initialize the device.""" - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "Initalizing KNX group address for %s (%s)", - self.name, self.address - ) + """Initialization of KNXModule.""" + self.hass = hass + self.config = config + self.initialized = False + self.init_xknx() + self.register_callbacks() - def handle_knx_message(addr, data): - """Handle an incoming KNX frame. + def init_xknx(self): + """Initialization of KNX object.""" + from xknx import XKNX + self.xknx = XKNX( + config=self.config_file(), + loop=self.hass.loop) - Handle an incoming frame and update our status if it contains - information relating to this device. - """ - if (addr == self.state_address) or (addr == self.address): - self._state = data[0] - self.schedule_update_ha_state() + @asyncio.coroutine + def start(self): + """Start KNX object. Connect to tunneling or Routing device.""" + connection_config = self.connection_config() + yield from self.xknx.start( + state_updater=True, + connection_config=connection_config) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + self.initialized = True - KNXTUNNEL.register_listener(self.address, handle_knx_message) - if self.state_address: - KNXTUNNEL.register_listener(self.state_address, handle_knx_message) + @asyncio.coroutine + def stop(self, event): + """Stop KNX object. Disconnect from tunneling or Routing device.""" + yield from self.xknx.stop() - @property - def name(self): - """Return the entity's display name.""" - return self._config.name + def config_file(self): + """Resolve and return the full path of xknx.yaml if configured.""" + config_file = self.config[DOMAIN].get(CONF_KNX_CONFIG) + if not config_file: + return None + if not config_file.startswith("/"): + return self.hass.config.path(config_file) + return config_file - @property - def config(self): - """Return the entity's configuration.""" - return self._config + def connection_config(self): + """Return the connection_config.""" + if CONF_KNX_TUNNELING in self.config[DOMAIN]: + return self.connection_config_tunneling() + elif CONF_KNX_ROUTING in self.config[DOMAIN]: + return self.connection_config_routing() + return self.connection_config_auto() - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll + def connection_config_routing(self): + """Return the connection_config if routing is configured.""" + from xknx.io import ConnectionConfig, ConnectionType + local_ip = \ + self.config[DOMAIN][CONF_KNX_ROUTING].get(CONF_KNX_LOCAL_IP) + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + local_ip=local_ip) - @property - def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + def connection_config_tunneling(self): + """Return the connection_config if tunneling is configured.""" + from xknx.io import ConnectionConfig, ConnectionType, \ + DEFAULT_MCAST_PORT + gateway_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_HOST) + gateway_port = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_PORT) + local_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_KNX_LOCAL_IP) + if gateway_port is None: + gateway_port = DEFAULT_MCAST_PORT + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=gateway_ip, + gateway_port=gateway_port, + local_ip=local_ip) - @property - def address(self): - """Return the KNX group address.""" - return self._config.address + def connection_config_auto(self): + """Return the connection_config if auto is configured.""" + # pylint: disable=no-self-use + from xknx.io import ConnectionConfig + return ConnectionConfig() - @property - def state_address(self): - """Return the KNX group address.""" - return self._config.state_address + def register_callbacks(self): + """Register callbacks within XKNX object.""" + if CONF_KNX_FIRE_EVENT in self.config[DOMAIN] and \ + self.config[DOMAIN][CONF_KNX_FIRE_EVENT]: + from xknx.knx import AddressFilter + address_filters = list(map( + AddressFilter, + self.config[DOMAIN][CONF_KNX_FIRE_EVENT_FILTER])) + self.xknx.telegram_queue.register_telegram_received_cb( + self.telegram_received_cb, address_filters) - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def group_write(self, value): - """Write to the group address.""" - KNXTUNNEL.group_write(self.address, [value]) - - def update(self): - """Get the state from KNX bus or cache.""" - from knxip.core import KNXException - - try: - if self.state_address: - res = KNXTUNNEL.group_read( - self.state_address, use_cache=self.cache) - else: - res = KNXTUNNEL.group_read(self.address, use_cache=self.cache) - - if res: - self._state = res[0] - self._data = res - else: - _LOGGER.debug( - "%s: unable to read from KNX address: %s (None)", - self.name, self.address - ) - - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, self.address - ) - return False - - -class KNXMultiAddressDevice(Entity): - """Representation of devices connected to a multiple KNX group address. - - This is needed for devices like dimmers or shutter actuators as they have - to be controlled by multiple group addresses. - """ - - def __init__(self, hass, config, required, optional=None): - """Initialize the device. - - The namelist argument lists the required addresses. E.g. for a dimming - actuators, the namelist might look like: - onoff_address: 0/0/1 - brightness_address: 0/0/2 - """ - from knxip.core import parse_group_address, KNXException - - self.names = {} - self.values = {} - - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "%s: initalizing KNX multi address device", - self.name - ) - - settings = self._config.config - if config.address: - _LOGGER.debug( - "%s: base address: address=%s", - self.name, settings.get('address') - ) - self.names[config.address] = 'base' - if config.state_address: - _LOGGER.debug( - "%s, state address: state_address=%s", - self.name, settings.get('state_address') - ) - self.names[config.state_address] = 'state' - - # parse required addresses - for name in required: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - if addr is None: - _LOGGER.error( - "%s: Required KNX group address %s missing", - self.name, paramname - ) - raise KNXException( - "%s: Group address for {} missing in " - "configuration for {}".format( - self.name, paramname - ) - ) - _LOGGER.debug( - "%s: (required parameter) %s=%s", - self.name, paramname, addr - ) - addr = parse_group_address(addr) - self.names[addr] = name - - # parse optional addresses - for name in optional: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - _LOGGER.debug( - "%s: (optional parameter) %s=%s", - self.name, paramname, addr - ) - if addr: - try: - addr = parse_group_address(addr) - except KNXException: - _LOGGER.exception( - "%s: cannot parse group address %s", - self.name, addr - ) - self.names[addr] = name - - @property - def name(self): - """Return the entity's display name.""" - return self._config.name - - @property - def config(self): - """Return the entity's configuration.""" - return self._config - - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll - - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def has_attribute(self, name): - """Check if the attribute with the given name is defined. - - This is mostly important for optional addresses. - """ - for attributename in self.names.values(): - if attributename == name: - return True + @asyncio.coroutine + def telegram_received_cb(self, telegram): + """Callback invoked after a KNX telegram was received.""" + self.hass.bus.fire('knx_event', { + 'address': telegram.group_address.str(), + 'data': telegram.payload.value + }) + # False signals XKNX to proceed with processing telegrams. return False - def set_percentage(self, name, percentage): - """Set a percentage in knx for a given attribute. + @asyncio.coroutine + def service_send_to_knx_bus(self, call): + """Service for sending an arbitray KNX message to the KNX bus.""" + from xknx.knx import Telegram, Address, DPTBinary, DPTArray + attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) + attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - percentage = abs(percentage) # only accept positive values - scaled_value = percentage * 255 / 100 - value = min(255, scaled_value) - return self.set_int_value(name, value) + def calculate_payload(attr_payload): + """Calculate payload depending on type of attribute.""" + if isinstance(attr_payload, int): + return DPTBinary(attr_payload) + return DPTArray(attr_payload) + payload = calculate_payload(attr_payload) + address = Address(attr_address) - def get_percentage(self, name): - """Get a percentage from knx for a given attribute. + telegram = Telegram() + telegram.payload = payload + telegram.group_address = address + yield from self.xknx.telegrams.put(telegram) - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - value = self.get_int_value(name) - percentage = round(value * 100 / 255) - return percentage - def set_int_value(self, name, value, num_bytes=1): - """Set an integer value for a given attribute.""" - # KNX packets are big endian - value = round(value) # only accept integers - b_value = value.to_bytes(num_bytes, byteorder='big') - return self.set_value(name, list(b_value)) +class KNXAutomation(): + """Wrapper around xknx.devices.ActionCallback object..""" - def get_int_value(self, name): - """Get an integer value for a given attribute.""" - # KNX packets are big endian - summed_value = 0 - raw_value = self.value(name) - try: - # convert raw value in bytes - for val in raw_value: - summed_value *= 256 - summed_value += val - except TypeError: - # pknx returns a non-iterable type for unsuccessful reads - pass + def __init__(self, hass, device, hook, action, counter=1): + """Initialize Automation class.""" + self.hass = hass + self.device = device + script_name = "{} turn ON script".format(device.get_name()) + self.script = Script(hass, action, script_name) - return summed_value - - def value(self, name): - """Return the value to a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - res = KNXTUNNEL.group_read(addr, use_cache=self.cache) - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, addr - ) - return False - - return res - - def set_value(self, name, value): - """Set the value of a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - KNXTUNNEL.group_write(addr, value) - except KNXException: - _LOGGER.exception( - "%s: unable to write to KNX address: %s", - self.name, addr - ) - return False - - return True + import xknx + self.action = xknx.devices.ActionCallback( + hass.data[DATA_KNX].xknx, + self.script.async_run, + hook=hook, + counter=counter) + device.actions.append(self.action) diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index d89d45e99a7..62261944feb 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -1,17 +1,17 @@ """ -Support KNX Lighting actuators. +Support for KNX/IP lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/Light.knx/ +https://home-assistant.io/components/light.knx/ """ -import logging +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.components.light import (Light, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, - ATTR_BRIGHTNESS) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.light import PLATFORM_SCHEMA, Light, \ + SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -19,8 +19,6 @@ CONF_STATE_ADDRESS = 'state_address' CONF_BRIGHTNESS_ADDRESS = 'brightness_address' CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' -_LOGGER = logging.getLogger(__name__) - DEFAULT_NAME = 'KNX Light' DEPENDENCIES = ['knx'] @@ -33,84 +31,136 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX light platform.""" - add_devices([KNXLight(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up light(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXLight(KNXMultiAddressDevice, Light): - """Representation of a KNX Light device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up lights for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXLight(hass, device)) + add_devices(entities) - def __init__(self, hass, config): - """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - [], # required - optional=['state', 'brightness', 'brightness_state'] - ) - self._hass = hass - self._supported_features = 0 - if CONF_BRIGHTNESS_ADDRESS in config.config: - _LOGGER.debug("%s is dimmable", self.name) - self._supported_features = self._supported_features | \ - SUPPORT_BRIGHTNESS - self._brightness = None +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up light for KNX platform configured within plattform.""" + import xknx + light = xknx.devices.Light( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_switch=config.get(CONF_ADDRESS), + group_address_switch_state=config.get(CONF_STATE_ADDRESS), + group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS), + group_address_brightness_state=config.get( + CONF_BRIGHTNESS_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(light) + add_devices([KNXLight(hass, light)]) - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn on", self.name) - self.set_value('base', [1]) - self._state = 1 +class KNXLight(Light): + """Representation of a KNX light.""" - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - _LOGGER.debug("turn_on requested brightness for light: %s is: %s ", - self.name, self._brightness) - assert self._brightness <= 255 - self.set_value("brightness", [self._brightness]) + def __init__(self, hass, device): + """Initialization of KNXLight.""" + self.device = device + self.hass = hass + self.async_register_callbacks() - if not self.should_poll: - self.schedule_update_ha_state() + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) - def turn_off(self, **kwargs): - """Turn the switch off. + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn off", self.name) - self.set_value('base', [0]) - self._state = 0 - if not self.should_poll: - self.schedule_update_ha_state() + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self.device.brightness \ + if self.device.supports_dimming else \ + None + + @property + def xy_color(self): + """Return the XY color value [float, float].""" + return None + + @property + def rgb_color(self): + """Return the RBG color value.""" + return None + + @property + def color_temp(self): + """Return the CT color temperature.""" + return None + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return None + + @property + def effect_list(self): + """Return the list of supported effects.""" + return None + + @property + def effect(self): + """Return the current effect.""" + return None @property def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + """Return true if light is on.""" + return self.device.state @property def supported_features(self): """Flag supported features.""" - return self._supported_features + flags = 0 + if self.device.supports_dimming: + flags |= SUPPORT_BRIGHTNESS + return flags - def update(self): - """Update device state.""" - super().update() - if self.has_attribute('brightness_state'): - value = self.value('brightness_state') - if value is not None: - self._brightness = int.from_bytes(value, byteorder='little') - _LOGGER.debug("%s: brightness = %d", - self.name, self._brightness) + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: + yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + else: + yield from self.device.set_on() - if self.has_attribute('state'): - self._state = self.value("state")[0] - _LOGGER.debug("%s: state = %d", self.name, self._state) - - def should_poll(self): - """No polling needed for a KNX light.""" - return False + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + yield from self.device.set_off() diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 1c17d1a795a..9496ff1d596 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -82,8 +82,6 @@ def async_setup(hass, config): """Set up a notify platform.""" if p_config is None: p_config = {} - if discovery_info is None: - discovery_info = {} platform = yield from async_prepare_setup_platform( hass, config, DOMAIN, p_type) @@ -105,8 +103,12 @@ def async_setup(hass, config): raise HomeAssistantError("Invalid notify platform.") if notify_service is None: - _LOGGER.error( - "Failed to initialize notification service %s", p_type) + # Platforms can decide not to create a service based + # on discovery data. + if discovery_info is None: + _LOGGER.error( + "Failed to initialize notification service %s", + p_type) return except Exception: # pylint: disable=broad-except @@ -115,6 +117,9 @@ def async_setup(hass, config): notify_service.hass = hass + if discovery_info is None: + discovery_info = {} + @asyncio.coroutine def async_notify_message(service): """Handle sending notification message service calls.""" diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py new file mode 100644 index 00000000000..c5dbcb0d4ad --- /dev/null +++ b/homeassistant/components/notify/knx.py @@ -0,0 +1,99 @@ +""" +KNX/IP notification service. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/notify.knx/ +""" +import asyncio +import voluptuous as vol + +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.notify import PLATFORM_SCHEMA, \ + BaseNotificationService +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +DEFAULT_NAME = 'KNX Notify' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +@asyncio.coroutine +def async_get_service(hass, config, discovery_info=None): + """Get the KNX notification service.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + return async_get_service_discovery(hass, discovery_info) \ + if discovery_info is not None else \ + async_get_service_config(hass, config) + + +@callback +def async_get_service_discovery(hass, discovery_info): + """Set up notifications for KNX platform configured via xknx.yaml.""" + notification_devices = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + notification_devices.append(device) + return \ + KNXNotificationService(hass, notification_devices) \ + if notification_devices else \ + None + + +@callback +def async_get_service_config(hass, config): + """Set up notification for KNX platform configured within plattform.""" + import xknx + notification = xknx.devices.Notification( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(notification) + return KNXNotificationService(hass, [notification, ]) + + +class KNXNotificationService(BaseNotificationService): + """Implement demo notification service.""" + + def __init__(self, hass, devices): + """Initialize the service.""" + self.hass = hass + self.devices = devices + + @property + def targets(self): + """Return a dictionary of registered targets.""" + ret = {} + for device in self.devices: + ret[device.name] = device.name + return ret + + @asyncio.coroutine + def async_send_message(self, message="", **kwargs): + """Send a notification to knx bus.""" + if "target" in kwargs: + yield from self._async_send_to_device(message, kwargs["target"]) + else: + yield from self._async_send_to_all_devices(message) + + @asyncio.coroutine + def _async_send_to_all_devices(self, message): + """Send a notification to knx bus to all connected devices.""" + for device in self.devices: + yield from device.set(message) + + @asyncio.coroutine + def _async_send_to_device(self, message, names): + """Send a notification to knx bus to device with given names.""" + for device in self.devices: + if device.name in names: + yield from device.set(message) diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 80a88ca925a..60f11d76e79 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -1,184 +1,111 @@ """ -Sensors of a KNX Device. +Support for KNX/IP sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/knx/ +https://home-assistant.io/components/sensor.knx/ """ -from enum import Enum - -import logging +import asyncio import voluptuous as vol -from homeassistant.const import ( - CONF_NAME, CONF_MAXIMUM, CONF_MINIMUM, - CONF_TYPE, TEMP_CELSIUS -) -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +CONF_ADDRESS = 'address' +CONF_TYPE = 'type' +DEFAULT_NAME = 'KNX Sensor' DEPENDENCIES = ['knx'] -DEFAULT_NAME = "KNX sensor" - -CONF_TEMPERATURE = 'temperature' -CONF_ADDRESS = 'address' -CONF_ILLUMINANCE = 'illuminance' -CONF_PERCENTAGE = 'percentage' -CONF_SPEED_MS = 'speed_ms' - - -class KNXAddressType(Enum): - """Enum to indicate conversion type for the KNX address.""" - - FLOAT = 1 - PERCENT = 2 - - -# define the fixed settings required for each sensor type -FIXED_SETTINGS_MAP = { - # Temperature as defined in KNX Standard 3.10 - 9.001 DPT_Value_Temp - CONF_TEMPERATURE: { - 'unit': TEMP_CELSIUS, - 'default_minimum': -273, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Speed m/s as defined in KNX Standard 3.10 - 9.005 DPT_Value_Wsp - CONF_SPEED_MS: { - 'unit': 'm/s', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Luminance(LUX) as defined in KNX Standard 3.10 - 9.004 DPT_Value_Lux - CONF_ILLUMINANCE: { - 'unit': 'lx', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Percentage(%) as defined in KNX Standard 3.10 - 5.001 DPT_Scaling - CONF_PERCENTAGE: { - 'unit': '%', - 'default_minimum': 0, - 'default_maximum': 100, - 'address_type': KNXAddressType.PERCENT - } -} - -SENSOR_TYPES = set(FIXED_SETTINGS_MAP.keys()) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), vol.Required(CONF_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MINIMUM): vol.Coerce(float), - vol.Optional(CONF_MAXIMUM): vol.Coerce(float) + vol.Optional(CONF_TYPE): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX Sensor platform.""" - add_devices([KNXSensor(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXSensor(KNXGroupAddress): - """Representation of a KNX Sensor device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXSensor(hass, device)) + add_devices(entities) - def __init__(self, hass, config): - """Initialize a KNX Float Sensor.""" - # set up the KNX Group address - KNXGroupAddress.__init__(self, hass, config) - device_type = config.config.get(CONF_TYPE) - sensor_config = FIXED_SETTINGS_MAP.get(device_type) +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up sensor for KNX platform configured within plattform.""" + import xknx + sensor = xknx.devices.Sensor( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + value_type=config.get(CONF_TYPE)) + hass.data[DATA_KNX].xknx.devices.add(sensor) + add_devices([KNXSensor(hass, sensor)]) - if not sensor_config: - raise NotImplementedError() - # set up the conversion function based on the address type - address_type = sensor_config.get('address_type') - if address_type == KNXAddressType.FLOAT: - self.convert = convert_float - elif address_type == KNXAddressType.PERCENT: - self.convert = convert_percent - else: - raise NotImplementedError() +class KNXSensor(Entity): + """Representation of a KNX sensor.""" - # other settings - self._unit_of_measurement = sensor_config.get('unit') - default_min = float(sensor_config.get('default_minimum')) - default_max = float(sensor_config.get('default_maximum')) - self._minimum_value = config.config.get(CONF_MINIMUM, default_min) - self._maximum_value = config.config.get(CONF_MAXIMUM, default_max) - _LOGGER.debug( - "%s: configured additional settings: unit=%s, " - "min=%f, max=%f, type=%s", - self.name, self._unit_of_measurement, - self._minimum_value, self._maximum_value, str(address_type) - ) + def __init__(self, hass, device): + """Initialization of KNXSensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() - self._value = None + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False @property def state(self): - """Return the Value of the KNX Sensor.""" - return self._value + """Return the state of the sensor.""" + return self.device.resolve_state() @property def unit_of_measurement(self): - """Return the defined Unit of Measurement for the KNX Sensor.""" - return self._unit_of_measurement - - def update(self): - """Update KNX sensor.""" - super().update() - - self._value = None - - if self._data: - if self._data == 0: - value = 0 - else: - value = self.convert(self._data) - if self._minimum_value <= value <= self._maximum_value: - self._value = value + """Return the unit this state is expressed in.""" + return self.device.unit_of_measurement() @property - def cache(self): - """We don't want to cache any Sensor Value.""" - return False - - -def convert_float(raw_value): - """Conversion for 2 byte floating point values. - - 2byte Floating Point KNX Telegram. - Defined in KNX 3.7.2 - 3.10 - """ - from knxip.conversion import knx2_to_float - from knxip.core import KNXException - - try: - return knx2_to_float(raw_value) - except KNXException as exception: - _LOGGER.error("Can't convert %s to float (%s)", raw_value, exception) - - -def convert_percent(raw_value): - """Conversion for scaled byte values. - - 1byte percentage scaled KNX Telegram. - Defined in KNX 3.7.2 - 3.10. - """ - value = 0 - try: - value = raw_value[0] - except (IndexError, ValueError): - # pknx returns a non-iterable type for unsuccessful reads - _LOGGER.error("Can't convert %s to percent value", raw_value) - - return round(value * 100 / 255) + def device_state_attributes(self): + """Return the state attributes.""" + return None diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index d07df08ed5c..90b04239086 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -1,14 +1,16 @@ """ -Support KNX switching actuators. +Support for KNX/IP switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.knx/ """ +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -24,30 +26,85 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX switch platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up switch(es) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXSwitch(KNXGroupAddress, SwitchDevice): - """Representation of a KNX switch device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up switches for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXSwitch(hass, device)) + add_devices(entities) - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 0 to the group address of the device - """ - self.group_write(1) - self._state = [1] - if not self.should_poll: - self.schedule_update_ha_state() +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up switch for KNX platform configured within plattform.""" + import xknx + switch = xknx.devices.Switch( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + group_address_state=config.get(CONF_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(switch) + add_devices([KNXSwitch(hass, switch)]) - def turn_off(self, **kwargs): - """Turn the switch off. - This sends a value 1 to the group address of the device - """ - self.group_write(0) - self._state = [0] - if not self.should_poll: - self.schedule_update_ha_state() +class KNXSwitch(SwitchDevice): + """Representation of a KNX switch.""" + + def __init__(self, hass, device): + """Initialization of KNXSwitch.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self.device.state + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the device on.""" + yield from self.device.set_on() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the device off.""" + yield from self.device.set_off() diff --git a/requirements_all.txt b/requirements_all.txt index 9114d774234..1bafef96fba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -362,9 +362,6 @@ jsonrpc-websocket==0.5 # homeassistant.scripts.keyring keyring>=9.3,<10.0 -# homeassistant.components.knx -knxip==0.5 - # homeassistant.components.device_tracker.owntracks libnacl==1.5.2 @@ -1012,6 +1009,9 @@ xbee-helper==0.0.7 # homeassistant.components.sensor.xbox_live xboxapi==0.1.1 +# homeassistant.components.knx +xknx==0.7.13 + # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 From 3b2bf1d567d8debc80284a0c417c476c3acba6af Mon Sep 17 00:00:00 2001 From: Sergey Isachenko Date: Thu, 7 Sep 2017 19:20:27 +0300 Subject: [PATCH 136/277] Fix for potential issue with tesla initialization (#9307) Fix for potential issue with tesla initialization --- homeassistant/components/tesla.py | 36 +++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py index e48d805abab..08006310dc7 100644 --- a/homeassistant/components/tesla.py +++ b/homeassistant/components/tesla.py @@ -5,7 +5,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/tesla/ """ from collections import defaultdict +import logging +from urllib.error import HTTPError import voluptuous as vol from homeassistant.const import ( @@ -19,6 +21,8 @@ REQUIREMENTS = ['teslajsonpy==0.0.11'] DOMAIN = 'tesla' +_LOGGER = logging.getLogger(__name__) + TESLA_ID_FORMAT = '{}_{}' TESLA_ID_LIST_SCHEMA = vol.Schema([int]) @@ -31,6 +35,9 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +NOTIFICATION_ID = 'tesla_integration_notification' +NOTIFICATION_TITLE = 'Tesla integration setup' + TESLA_COMPONENTS = [ 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' ] @@ -46,10 +53,31 @@ def setup(hass, base_config): password = config.get(CONF_PASSWORD) update_interval = config.get(CONF_SCAN_INTERVAL) if hass.data.get(DOMAIN) is None: - hass.data[DOMAIN] = { - 'controller': teslaApi(email, password, update_interval), - 'devices': defaultdict(list) - } + try: + hass.data[DOMAIN] = { + 'controller': teslaApi(email, password, update_interval), + 'devices': defaultdict(list) + } + _LOGGER.debug("Connected to the Tesla API.") + except HTTPError as ex: + if ex.code == 401: + hass.components.persistent_notification.create( + "Error:
Please check username and password." + "You will need to restart Home Assistant after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + else: + hass.components.persistent_notification.create( + "Error:
Can't communicate with Tesla API.
" + "Error code: {} Reason: {}" + "You will need to restart Home Assistant after fixing." + "".format(ex.code, ex.reason), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + _LOGGER.error("Unable to communicate with Tesla API: %s", + ex.reason) + + return False all_devices = hass.data[DOMAIN]['controller'].list_vehicles() From d1ef47384de424bd0f5bc0a049039e53f40ca57e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 8 Sep 2017 08:05:51 -0600 Subject: [PATCH 137/277] Adds the AirVisual air quality sensor platform (#9320) * Adds the AirVisual air quality sensor platform * Updated .coveragerc * Removed some un-needed code * Adding strangely-necessary pylint disable * Removing a Python3.5-specific dict combiner method * Restarting stuck coverage test * Added units to AQI sensor (to get nice graph) * Making collaborator-requested changes * Removing unnecessary parameter from data object --- .coveragerc | 1 + homeassistant/components/sensor/airvisual.py | 289 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 293 insertions(+) create mode 100644 homeassistant/components/sensor/airvisual.py diff --git a/.coveragerc b/.coveragerc index 2fc424e91f6..d5eb32e670c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -427,6 +427,7 @@ omit = homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py + homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py new file mode 100644 index 00000000000..7b077aa38ee --- /dev/null +++ b/homeassistant/components/sensor/airvisual.py @@ -0,0 +1,289 @@ +""" +Support for AirVisual air quality sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.airvisual/ +""" + +import asyncio +from logging import getLogger +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_STATE, CONF_API_KEY, + CONF_LATITUDE, CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) +REQUIREMENTS = ['pyairvisual==0.1.0'] + +ATTR_CITY = 'city' +ATTR_COUNTRY = 'country' +ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' +ATTR_POLLUTANT_UNIT = 'pollutant_unit' +ATTR_TIMESTAMP = 'timestamp' + +CONF_RADIUS = 'radius' + +MASS_PARTS_PER_MILLION = 'ppm' +MASS_PARTS_PER_BILLION = 'ppb' +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for Sensitive Groups', + 'minimum': 101, + 'maximum': 150 +}, { + 'label': 'Unhealthy', + 'minimum': 151, + 'maximum': 200 +}, { + 'label': 'Very Unhealthy', + 'minimum': 201, + 'maximum': 300 +}, { + 'label': 'Hazardous', + 'minimum': 301, + 'maximum': 10000 +}] +POLLUTANT_MAPPING = { + 'co': { + 'label': 'Carbon Monoxide', + 'unit': MASS_PARTS_PER_MILLION + }, + 'n2': { + 'label': 'Nitrogen Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, + 'o3': { + 'label': 'Ozone', + 'unit': MASS_PARTS_PER_BILLION + }, + 'p1': { + 'label': 'PM10', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 'p2': { + 'label': 'PM2.5', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 's2': { + 'label': 'Sulfur Dioxide', + 'unit': MASS_PARTS_PER_BILLION + } +} + +SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), + vol.Optional(CONF_LATITUDE): + cv.latitude, + vol.Optional(CONF_LONGITUDE): + cv.longitude, + vol.Optional(CONF_RADIUS, default=1000): + cv.positive_int, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Configure the platform and add the sensors.""" + import pyairvisual as pav + + api_key = config.get(CONF_API_KEY) + _LOGGER.debug('AirVisual API Key: %s', api_key) + + monitored_locales = config.get(CONF_MONITORED_CONDITIONS) + _LOGGER.debug('Monitored Conditions: %s', monitored_locales) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + _LOGGER.debug('AirVisual Latitude: %s', latitude) + + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + _LOGGER.debug('AirVisual Longitude: %s', longitude) + + radius = config.get(CONF_RADIUS) + _LOGGER.debug('AirVisual Radius: %s', radius) + + data = AirVisualData(pav.Client(api_key), latitude, longitude, radius) + + sensors = [] + for locale in monitored_locales: + for sensor_class, name, icon in SENSOR_TYPES: + sensors.append(globals()[sensor_class](data, name, icon, locale)) + + async_add_devices(sensors, True) + + +def merge_two_dicts(dict1, dict2): + """Merge two dicts into a new dict as a shallow copy.""" + final = dict1.copy() + final.update(dict2) + return final + + +class AirVisualBaseSensor(Entity): + """Define a base class for all of our sensors.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + self._data = data + self._icon = icon + self._locale = locale + self._name = name + self._state = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return { + ATTR_ATTRIBUTION: 'AirVisual©', + ATTR_CITY: self._data.city, + ATTR_COUNTRY: self._data.country, + ATTR_STATE: self._data.state, + ATTR_TIMESTAMP: self._data.pollution_info.get('ts') + } + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return '{0} {1}'.format(SENSOR_LOCALES[self._locale], self._name) + + @property + def state(self): + """Return the state.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + _LOGGER.debug('updating sensor: %s', self._name) + self._data.update() + + +class AirPollutionLevelSensor(AirVisualBaseSensor): + """Define a sensor to measure air pollution level.""" + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + + try: + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level.get('label') + except ValueError: + self._state = None + + +class AirQualityIndexSensor(AirVisualBaseSensor): + """Define a sensor to measure AQI.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return '' + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + self._state = self._data.pollution_info.get( + 'aqi{0}'.format(self._locale)) + + +class MainPollutantSensor(AirVisualBaseSensor): + """Define a sensor to the main pollutant of an area.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + super().__init__(data, name, icon, locale) + self._symbol = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return merge_two_dicts(super().device_state_attributes, { + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + pollution_info = POLLUTANT_MAPPING.get(symbol, {}) + self._state = pollution_info.get('label') + self._unit = pollution_info.get('unit') + self._symbol = symbol + + +class AirVisualData(object): + """Define an object to hold sensor data.""" + + def __init__(self, client, latitude, longitude, radius): + """Initialize.""" + self.city = None + self._client = client + self.country = None + self.latitude = latitude + self.longitude = longitude + self.pollution_info = None + self.radius = radius + self.state = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update with new AirVisual data.""" + import pyairvisual.exceptions as exceptions + + try: + resp = self._client.nearest_city(self.latitude, self.longitude, + self.radius).get('data') + _LOGGER.debug('New data retrieved: %s', resp) + + self.city = resp.get('city') + self.state = resp.get('state') + self.country = resp.get('country') + self.pollution_info = resp.get('current').get('pollution') + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to update sensor data') + _LOGGER.debug(exc_info) diff --git a/requirements_all.txt b/requirements_all.txt index 1bafef96fba..80401ed3733 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -538,6 +538,9 @@ pyRFXtrx==0.20.1 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.sensor.airvisual +pyairvisual==0.1.0 + # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 From 5ec555280384ec4fcdd4257f09826d2797f10450 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 8 Sep 2017 21:19:49 -0700 Subject: [PATCH 138/277] Cleanup input_text (#9326) --- homeassistant/components/input_text.py | 37 +++++++++----------------- tests/components/test_input_text.py | 10 +++---- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py index d17837b0ced..583181fe453 100755 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -25,17 +25,15 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' CONF_INITIAL = 'initial' CONF_MIN = 'min' CONF_MAX = 'max' -CONF_DISABLED = 'disabled' ATTR_VALUE = 'value' ATTR_MIN = 'min' ATTR_MAX = 'max' ATTR_PATTERN = 'pattern' -ATTR_DISABLED = 'disabled' -SERVICE_SELECT_VALUE = 'select_value' +SERVICE_SET_VALUE = 'set_value' -SERVICE_SELECT_VALUE_SCHEMA = vol.Schema({ +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_VALUE): cv.string, }) @@ -65,16 +63,15 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_ICON): cv.icon, vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(ATTR_PATTERN): cv.string, - vol.Optional(CONF_DISABLED, default=False): cv.boolean, }, _cv_input_text) }) }, required=True, extra=vol.ALLOW_EXTRA) @bind_hass -def select_value(hass, entity_id, value): +def set_value(hass, entity_id, value): """Set input_text to value.""" - hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, { + hass.services.call(DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value, }) @@ -95,28 +92,27 @@ def async_setup(hass, config): icon = cfg.get(CONF_ICON) unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) pattern = cfg.get(ATTR_PATTERN) - disabled = cfg.get(CONF_DISABLED) entities.append(InputText( object_id, name, initial, minimum, maximum, icon, unit, - pattern, disabled)) + pattern)) if not entities: return False @asyncio.coroutine - def async_select_value_service(call): + def async_set_value_service(call): """Handle a calls to the input box services.""" target_inputs = component.async_extract_from_service(call) - tasks = [input_text.async_select_value(call.data[ATTR_VALUE]) + tasks = [input_text.async_set_value(call.data[ATTR_VALUE]) for input_text in target_inputs] if tasks: yield from asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( - DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service, - schema=SERVICE_SELECT_VALUE_SCHEMA) + DOMAIN, SERVICE_SET_VALUE, async_set_value_service, + schema=SERVICE_SET_VALUE_SCHEMA) yield from component.async_add_entities(entities) return True @@ -126,8 +122,8 @@ class InputText(Entity): """Represent a text box.""" def __init__(self, object_id, name, initial, minimum, maximum, icon, - unit, pattern, disabled): - """Initialize a select input.""" + unit, pattern): + """Initialize a text input.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name self._current_value = initial @@ -136,7 +132,6 @@ class InputText(Entity): self._icon = icon self._unit = unit self._pattern = pattern - self._disabled = disabled @property def should_poll(self): @@ -145,7 +140,7 @@ class InputText(Entity): @property def name(self): - """Return the name of the select input box.""" + """Return the name of the text input entity.""" return self._name @property @@ -163,11 +158,6 @@ class InputText(Entity): """Return the unit the value is expressed in.""" return self._unit - @property - def disabled(self): - """Return the disabled flag.""" - return self._disabled - @property def state_attributes(self): """Return the state attributes.""" @@ -175,7 +165,6 @@ class InputText(Entity): ATTR_MIN: self._minimum, ATTR_MAX: self._maximum, ATTR_PATTERN: self._pattern, - ATTR_DISABLED: self._disabled, } @asyncio.coroutine @@ -192,7 +181,7 @@ class InputText(Entity): self._current_value = value @asyncio.coroutine - def async_select_value(self, value): + def async_set_value(self, value): """Select new value.""" if len(value) < self._minimum or len(value) > self._maximum: _LOGGER.warning("Invalid value: %s (length range %s - %s)", diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py index 81b1f58aa87..be22e1122ea 100755 --- a/tests/components/test_input_text.py +++ b/tests/components/test_input_text.py @@ -5,7 +5,7 @@ import unittest from homeassistant.core import CoreState, State from homeassistant.setup import setup_component, async_setup_component -from homeassistant.components.input_text import (DOMAIN, select_value) +from homeassistant.components.input_text import (DOMAIN, set_value) from tests.common import get_test_home_assistant, mock_restore_cache @@ -38,8 +38,8 @@ class TestInputText(unittest.TestCase): self.assertFalse( setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) - def test_select_value(self): - """Test select_value method.""" + def test_set_value(self): + """Test set_value method.""" self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { 'test_1': { 'initial': 'test', @@ -52,13 +52,13 @@ class TestInputText(unittest.TestCase): state = self.hass.states.get(entity_id) self.assertEqual('test', str(state.state)) - select_value(self.hass, entity_id, 'testing') + set_value(self.hass, entity_id, 'testing') self.hass.block_till_done() state = self.hass.states.get(entity_id) self.assertEqual('testing', str(state.state)) - select_value(self.hass, entity_id, 'testing too long') + set_value(self.hass, entity_id, 'testing too long') self.hass.block_till_done() state = self.hass.states.get(entity_id) From c44972c2c9866d899c33261bb207f41c41b605c0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 8 Sep 2017 23:08:38 -0700 Subject: [PATCH 139/277] Update frontend --- homeassistant/components/frontend/version.py | 4 ++-- .../frontend/www_static/frontend.html | 8 ++++---- .../frontend/www_static/frontend.html.gz | Bin 167216 -> 167890 bytes .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-config.html | 2 +- .../www_static/panels/ha-panel-config.html.gz | Bin 32839 -> 32428 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5139 -> 5136 bytes 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 54d9ffda6c5..21215e14d23 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "3ce24a1e0bc1c6620373f38a2d11b359", + "frontend.html": "c04709d3517dd3fd34b2f7d6bba6ec8e", "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "37803526cb203a8f1eaacd528fb2c7b3", + "panels/ha-panel-config.html": "0091008947ed61a6691c28093a6a6fcd", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 02063e4df3e..d6a15a0d610 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,7 +8,7 @@ .flex-1{-ms-flex:1 1 0.000000001px;-webkit-flex:1;flex:1;-webkit-flex-basis:0.000000001px;flex-basis:0.000000001px;}.flex-2{-ms-flex:2;-webkit-flex:2;flex:2;}.flex-3{-ms-flex:3;-webkit-flex:3;flex:3;}.flex-4{-ms-flex:4;-webkit-flex:4;flex:4;}.flex-5{-ms-flex:5;-webkit-flex:5;flex:5;}.flex-6{-ms-flex:6;-webkit-flex:6;flex:6;}.flex-7{-ms-flex:7;-webkit-flex:7;flex:7;}.flex-8{-ms-flex:8;-webkit-flex:8;flex:8;}.flex-9{-ms-flex:9;-webkit-flex:9;flex:9;}.flex-10{-ms-flex:10;-webkit-flex:10;flex:10;}.flex-11{-ms-flex:11;-webkit-flex:11;flex:11;}.flex-12{-ms-flex:12;-webkit-flex:12;flex:12;} \ No newline at end of file + ha-script-editor{height:100%;} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 335c067e4b5e797fe65c72dd74463b0875e0733b..08a7f5002cd0f9d15d61a5e673e4c0d16885f183 100644 GIT binary patch delta 5781 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 delta 837 zcmV-L1G@Z>D3d6#fdqd;dm@F+EpKGcxIM8$&vkvlCsPs3+|ZYYAA10V*%L~e!zE8- zoWn$SeWjzm7+Id2a)0bjg%`?+VsTg&?S|a7#c0eYBRR3gJ`W8gE~mno z_;xT0T-WkK;Rcg_1z~?3_?GR?EXyB_@T(vAGZ8o)f;M3 zN?b5e=+9=M?f5c4_HjeYf`uS7g4}jS-^2BX6wzu?A`5?|?aGloMYMW>6C(5{V;FQg zo%%uo_xF%p$@wbgS&5AYZaDQkSOBp;w!KNneS6Fi2XZ!Y?VyP*Lfw%MLzJrcrEh!A zJ^mjolR25x_B_k=k*6Fm#NmK53ljR$vuxX*j`!jj;18^)7#SnqPeE0ZSQ4qmmyRDy zyh#wYR1_;MXv-f4JQJT5P!0ctxPOq@Cx2dY!`+cHvxSYk2KRR1f|HR0Ry@~|@FTMX P2m}lWfbbiX12q5uxuS Date: Fri, 8 Sep 2017 23:08:38 -0700 Subject: [PATCH 140/277] Update frontend --- homeassistant/components/frontend/version.py | 4 ++-- .../frontend/www_static/frontend.html | 8 ++++---- .../frontend/www_static/frontend.html.gz | Bin 167216 -> 167890 bytes .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-config.html | 2 +- .../www_static/panels/ha-panel-config.html.gz | Bin 32839 -> 32428 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5139 -> 5136 bytes 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 54d9ffda6c5..21215e14d23 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "3ce24a1e0bc1c6620373f38a2d11b359", + "frontend.html": "c04709d3517dd3fd34b2f7d6bba6ec8e", "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "37803526cb203a8f1eaacd528fb2c7b3", + "panels/ha-panel-config.html": "0091008947ed61a6691c28093a6a6fcd", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 02063e4df3e..d6a15a0d610 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,7 +8,7 @@ .flex-1{-ms-flex:1 1 0.000000001px;-webkit-flex:1;flex:1;-webkit-flex-basis:0.000000001px;flex-basis:0.000000001px;}.flex-2{-ms-flex:2;-webkit-flex:2;flex:2;}.flex-3{-ms-flex:3;-webkit-flex:3;flex:3;}.flex-4{-ms-flex:4;-webkit-flex:4;flex:4;}.flex-5{-ms-flex:5;-webkit-flex:5;flex:5;}.flex-6{-ms-flex:6;-webkit-flex:6;flex:6;}.flex-7{-ms-flex:7;-webkit-flex:7;flex:7;}.flex-8{-ms-flex:8;-webkit-flex:8;flex:8;}.flex-9{-ms-flex:9;-webkit-flex:9;flex:9;}.flex-10{-ms-flex:10;-webkit-flex:10;flex:10;}.flex-11{-ms-flex:11;-webkit-flex:11;flex:11;}.flex-12{-ms-flex:12;-webkit-flex:12;flex:12;} \ No newline at end of file + ha-script-editor{height:100%;} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 335c067e4b5e797fe65c72dd74463b0875e0733b..08a7f5002cd0f9d15d61a5e673e4c0d16885f183 100644 GIT binary patch delta 5781 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 delta 837 zcmV-L1G@Z>D3d6#fdqd;dm@F+EpKGcxIM8$&vkvlCsPs3+|ZYYAA10V*%L~e!zE8- zoWn$SeWjzm7+Id2a)0bjg%`?+VsTg&?S|a7#c0eYBRR3gJ`W8gE~mno z_;xT0T-WkK;Rcg_1z~?3_?GR?EXyB_@T(vAGZ8o)f;M3 zN?b5e=+9=M?f5c4_HjeYf`uS7g4}jS-^2BX6wzu?A`5?|?aGloMYMW>6C(5{V;FQg zo%%uo_xF%p$@wbgS&5AYZaDQkSOBp;w!KNneS6Fi2XZ!Y?VyP*Lfw%MLzJrcrEh!A zJ^mjolR25x_B_k=k*6Fm#NmK53ljR$vuxX*j`!jj;18^)7#SnqPeE0ZSQ4qmmyRDy zyh#wYR1_;MXv-f4JQJT5P!0ctxPOq@Cx2dY!`+cHvxSYk2KRR1f|HR0Ry@~|@FTMX P2m}lWfbbiX12q5uxuS Date: Sat, 9 Sep 2017 03:06:06 -0400 Subject: [PATCH 141/277] Bump pyHik version to add IO support (#9341) --- homeassistant/components/binary_sensor/hikvision.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 7f2127fcad5..df488cc0ed6 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.3'] +REQUIREMENTS = ['pyhik==0.1.4'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -47,6 +47,7 @@ DEVICE_CLASS_MAP = { 'PIR Alarm': 'motion', 'Face Detection': 'motion', 'Scene Change Detection': 'motion', + 'I/O': None, } CUSTOMIZE_SCHEMA = vol.Schema({ diff --git a/requirements_all.txt b/requirements_all.txt index 80401ed3733..703bbd6b184 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -609,7 +609,7 @@ pyfttt==0.3 pyharmony==1.0.16 # homeassistant.components.binary_sensor.hikvision -pyhik==0.1.3 +pyhik==0.1.4 # homeassistant.components.homematic pyhomematic==0.1.30 From d2d876945b2aae0aa7b2f201e9af482b47fc9d70 Mon Sep 17 00:00:00 2001 From: Sergey Isachenko Date: Thu, 7 Sep 2017 19:20:27 +0300 Subject: [PATCH 142/277] Fix for potential issue with tesla initialization (#9307) Fix for potential issue with tesla initialization --- homeassistant/components/tesla.py | 36 +++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py index e48d805abab..08006310dc7 100644 --- a/homeassistant/components/tesla.py +++ b/homeassistant/components/tesla.py @@ -5,7 +5,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/tesla/ """ from collections import defaultdict +import logging +from urllib.error import HTTPError import voluptuous as vol from homeassistant.const import ( @@ -19,6 +21,8 @@ REQUIREMENTS = ['teslajsonpy==0.0.11'] DOMAIN = 'tesla' +_LOGGER = logging.getLogger(__name__) + TESLA_ID_FORMAT = '{}_{}' TESLA_ID_LIST_SCHEMA = vol.Schema([int]) @@ -31,6 +35,9 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +NOTIFICATION_ID = 'tesla_integration_notification' +NOTIFICATION_TITLE = 'Tesla integration setup' + TESLA_COMPONENTS = [ 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' ] @@ -46,10 +53,31 @@ def setup(hass, base_config): password = config.get(CONF_PASSWORD) update_interval = config.get(CONF_SCAN_INTERVAL) if hass.data.get(DOMAIN) is None: - hass.data[DOMAIN] = { - 'controller': teslaApi(email, password, update_interval), - 'devices': defaultdict(list) - } + try: + hass.data[DOMAIN] = { + 'controller': teslaApi(email, password, update_interval), + 'devices': defaultdict(list) + } + _LOGGER.debug("Connected to the Tesla API.") + except HTTPError as ex: + if ex.code == 401: + hass.components.persistent_notification.create( + "Error:
Please check username and password." + "You will need to restart Home Assistant after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + else: + hass.components.persistent_notification.create( + "Error:
Can't communicate with Tesla API.
" + "Error code: {} Reason: {}" + "You will need to restart Home Assistant after fixing." + "".format(ex.code, ex.reason), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + _LOGGER.error("Unable to communicate with Tesla API: %s", + ex.reason) + + return False all_devices = hass.data[DOMAIN]['controller'].list_vehicles() From c539b5c12bfb3f1513a3d1e26962b7a61b50eb0e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 8 Sep 2017 08:05:51 -0600 Subject: [PATCH 143/277] Adds the AirVisual air quality sensor platform (#9320) * Adds the AirVisual air quality sensor platform * Updated .coveragerc * Removed some un-needed code * Adding strangely-necessary pylint disable * Removing a Python3.5-specific dict combiner method * Restarting stuck coverage test * Added units to AQI sensor (to get nice graph) * Making collaborator-requested changes * Removing unnecessary parameter from data object --- .coveragerc | 1 + homeassistant/components/sensor/airvisual.py | 289 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 293 insertions(+) create mode 100644 homeassistant/components/sensor/airvisual.py diff --git a/.coveragerc b/.coveragerc index 2fc424e91f6..d5eb32e670c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -427,6 +427,7 @@ omit = homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py + homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py new file mode 100644 index 00000000000..7b077aa38ee --- /dev/null +++ b/homeassistant/components/sensor/airvisual.py @@ -0,0 +1,289 @@ +""" +Support for AirVisual air quality sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.airvisual/ +""" + +import asyncio +from logging import getLogger +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_STATE, CONF_API_KEY, + CONF_LATITUDE, CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) +REQUIREMENTS = ['pyairvisual==0.1.0'] + +ATTR_CITY = 'city' +ATTR_COUNTRY = 'country' +ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' +ATTR_POLLUTANT_UNIT = 'pollutant_unit' +ATTR_TIMESTAMP = 'timestamp' + +CONF_RADIUS = 'radius' + +MASS_PARTS_PER_MILLION = 'ppm' +MASS_PARTS_PER_BILLION = 'ppb' +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for Sensitive Groups', + 'minimum': 101, + 'maximum': 150 +}, { + 'label': 'Unhealthy', + 'minimum': 151, + 'maximum': 200 +}, { + 'label': 'Very Unhealthy', + 'minimum': 201, + 'maximum': 300 +}, { + 'label': 'Hazardous', + 'minimum': 301, + 'maximum': 10000 +}] +POLLUTANT_MAPPING = { + 'co': { + 'label': 'Carbon Monoxide', + 'unit': MASS_PARTS_PER_MILLION + }, + 'n2': { + 'label': 'Nitrogen Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, + 'o3': { + 'label': 'Ozone', + 'unit': MASS_PARTS_PER_BILLION + }, + 'p1': { + 'label': 'PM10', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 'p2': { + 'label': 'PM2.5', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 's2': { + 'label': 'Sulfur Dioxide', + 'unit': MASS_PARTS_PER_BILLION + } +} + +SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), + vol.Optional(CONF_LATITUDE): + cv.latitude, + vol.Optional(CONF_LONGITUDE): + cv.longitude, + vol.Optional(CONF_RADIUS, default=1000): + cv.positive_int, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Configure the platform and add the sensors.""" + import pyairvisual as pav + + api_key = config.get(CONF_API_KEY) + _LOGGER.debug('AirVisual API Key: %s', api_key) + + monitored_locales = config.get(CONF_MONITORED_CONDITIONS) + _LOGGER.debug('Monitored Conditions: %s', monitored_locales) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + _LOGGER.debug('AirVisual Latitude: %s', latitude) + + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + _LOGGER.debug('AirVisual Longitude: %s', longitude) + + radius = config.get(CONF_RADIUS) + _LOGGER.debug('AirVisual Radius: %s', radius) + + data = AirVisualData(pav.Client(api_key), latitude, longitude, radius) + + sensors = [] + for locale in monitored_locales: + for sensor_class, name, icon in SENSOR_TYPES: + sensors.append(globals()[sensor_class](data, name, icon, locale)) + + async_add_devices(sensors, True) + + +def merge_two_dicts(dict1, dict2): + """Merge two dicts into a new dict as a shallow copy.""" + final = dict1.copy() + final.update(dict2) + return final + + +class AirVisualBaseSensor(Entity): + """Define a base class for all of our sensors.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + self._data = data + self._icon = icon + self._locale = locale + self._name = name + self._state = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return { + ATTR_ATTRIBUTION: 'AirVisual©', + ATTR_CITY: self._data.city, + ATTR_COUNTRY: self._data.country, + ATTR_STATE: self._data.state, + ATTR_TIMESTAMP: self._data.pollution_info.get('ts') + } + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return '{0} {1}'.format(SENSOR_LOCALES[self._locale], self._name) + + @property + def state(self): + """Return the state.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + _LOGGER.debug('updating sensor: %s', self._name) + self._data.update() + + +class AirPollutionLevelSensor(AirVisualBaseSensor): + """Define a sensor to measure air pollution level.""" + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + + try: + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level.get('label') + except ValueError: + self._state = None + + +class AirQualityIndexSensor(AirVisualBaseSensor): + """Define a sensor to measure AQI.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return '' + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + self._state = self._data.pollution_info.get( + 'aqi{0}'.format(self._locale)) + + +class MainPollutantSensor(AirVisualBaseSensor): + """Define a sensor to the main pollutant of an area.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + super().__init__(data, name, icon, locale) + self._symbol = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return merge_two_dicts(super().device_state_attributes, { + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + pollution_info = POLLUTANT_MAPPING.get(symbol, {}) + self._state = pollution_info.get('label') + self._unit = pollution_info.get('unit') + self._symbol = symbol + + +class AirVisualData(object): + """Define an object to hold sensor data.""" + + def __init__(self, client, latitude, longitude, radius): + """Initialize.""" + self.city = None + self._client = client + self.country = None + self.latitude = latitude + self.longitude = longitude + self.pollution_info = None + self.radius = radius + self.state = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update with new AirVisual data.""" + import pyairvisual.exceptions as exceptions + + try: + resp = self._client.nearest_city(self.latitude, self.longitude, + self.radius).get('data') + _LOGGER.debug('New data retrieved: %s', resp) + + self.city = resp.get('city') + self.state = resp.get('state') + self.country = resp.get('country') + self.pollution_info = resp.get('current').get('pollution') + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to update sensor data') + _LOGGER.debug(exc_info) diff --git a/requirements_all.txt b/requirements_all.txt index 1bafef96fba..80401ed3733 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -538,6 +538,9 @@ pyRFXtrx==0.20.1 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.sensor.airvisual +pyairvisual==0.1.0 + # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 From 74bfcde814a465b4be0061d4bb674ce63064e106 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 8 Sep 2017 21:19:49 -0700 Subject: [PATCH 144/277] Cleanup input_text (#9326) --- homeassistant/components/input_text.py | 37 +++++++++----------------- tests/components/test_input_text.py | 10 +++---- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py index d17837b0ced..583181fe453 100755 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -25,17 +25,15 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' CONF_INITIAL = 'initial' CONF_MIN = 'min' CONF_MAX = 'max' -CONF_DISABLED = 'disabled' ATTR_VALUE = 'value' ATTR_MIN = 'min' ATTR_MAX = 'max' ATTR_PATTERN = 'pattern' -ATTR_DISABLED = 'disabled' -SERVICE_SELECT_VALUE = 'select_value' +SERVICE_SET_VALUE = 'set_value' -SERVICE_SELECT_VALUE_SCHEMA = vol.Schema({ +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_VALUE): cv.string, }) @@ -65,16 +63,15 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_ICON): cv.icon, vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(ATTR_PATTERN): cv.string, - vol.Optional(CONF_DISABLED, default=False): cv.boolean, }, _cv_input_text) }) }, required=True, extra=vol.ALLOW_EXTRA) @bind_hass -def select_value(hass, entity_id, value): +def set_value(hass, entity_id, value): """Set input_text to value.""" - hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, { + hass.services.call(DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value, }) @@ -95,28 +92,27 @@ def async_setup(hass, config): icon = cfg.get(CONF_ICON) unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) pattern = cfg.get(ATTR_PATTERN) - disabled = cfg.get(CONF_DISABLED) entities.append(InputText( object_id, name, initial, minimum, maximum, icon, unit, - pattern, disabled)) + pattern)) if not entities: return False @asyncio.coroutine - def async_select_value_service(call): + def async_set_value_service(call): """Handle a calls to the input box services.""" target_inputs = component.async_extract_from_service(call) - tasks = [input_text.async_select_value(call.data[ATTR_VALUE]) + tasks = [input_text.async_set_value(call.data[ATTR_VALUE]) for input_text in target_inputs] if tasks: yield from asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( - DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service, - schema=SERVICE_SELECT_VALUE_SCHEMA) + DOMAIN, SERVICE_SET_VALUE, async_set_value_service, + schema=SERVICE_SET_VALUE_SCHEMA) yield from component.async_add_entities(entities) return True @@ -126,8 +122,8 @@ class InputText(Entity): """Represent a text box.""" def __init__(self, object_id, name, initial, minimum, maximum, icon, - unit, pattern, disabled): - """Initialize a select input.""" + unit, pattern): + """Initialize a text input.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name self._current_value = initial @@ -136,7 +132,6 @@ class InputText(Entity): self._icon = icon self._unit = unit self._pattern = pattern - self._disabled = disabled @property def should_poll(self): @@ -145,7 +140,7 @@ class InputText(Entity): @property def name(self): - """Return the name of the select input box.""" + """Return the name of the text input entity.""" return self._name @property @@ -163,11 +158,6 @@ class InputText(Entity): """Return the unit the value is expressed in.""" return self._unit - @property - def disabled(self): - """Return the disabled flag.""" - return self._disabled - @property def state_attributes(self): """Return the state attributes.""" @@ -175,7 +165,6 @@ class InputText(Entity): ATTR_MIN: self._minimum, ATTR_MAX: self._maximum, ATTR_PATTERN: self._pattern, - ATTR_DISABLED: self._disabled, } @asyncio.coroutine @@ -192,7 +181,7 @@ class InputText(Entity): self._current_value = value @asyncio.coroutine - def async_select_value(self, value): + def async_set_value(self, value): """Select new value.""" if len(value) < self._minimum or len(value) > self._maximum: _LOGGER.warning("Invalid value: %s (length range %s - %s)", diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py index 81b1f58aa87..be22e1122ea 100755 --- a/tests/components/test_input_text.py +++ b/tests/components/test_input_text.py @@ -5,7 +5,7 @@ import unittest from homeassistant.core import CoreState, State from homeassistant.setup import setup_component, async_setup_component -from homeassistant.components.input_text import (DOMAIN, select_value) +from homeassistant.components.input_text import (DOMAIN, set_value) from tests.common import get_test_home_assistant, mock_restore_cache @@ -38,8 +38,8 @@ class TestInputText(unittest.TestCase): self.assertFalse( setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) - def test_select_value(self): - """Test select_value method.""" + def test_set_value(self): + """Test set_value method.""" self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { 'test_1': { 'initial': 'test', @@ -52,13 +52,13 @@ class TestInputText(unittest.TestCase): state = self.hass.states.get(entity_id) self.assertEqual('test', str(state.state)) - select_value(self.hass, entity_id, 'testing') + set_value(self.hass, entity_id, 'testing') self.hass.block_till_done() state = self.hass.states.get(entity_id) self.assertEqual('testing', str(state.state)) - select_value(self.hass, entity_id, 'testing too long') + set_value(self.hass, entity_id, 'testing too long') self.hass.block_till_done() state = self.hass.states.get(entity_id) From 3065575777f4e74059119c321356fe8fa404ae9a Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Sat, 9 Sep 2017 03:06:06 -0400 Subject: [PATCH 145/277] Bump pyHik version to add IO support (#9341) --- homeassistant/components/binary_sensor/hikvision.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 7f2127fcad5..df488cc0ed6 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.3'] +REQUIREMENTS = ['pyhik==0.1.4'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -47,6 +47,7 @@ DEVICE_CLASS_MAP = { 'PIR Alarm': 'motion', 'Face Detection': 'motion', 'Scene Change Detection': 'motion', + 'I/O': None, } CUSTOMIZE_SCHEMA = vol.Schema({ diff --git a/requirements_all.txt b/requirements_all.txt index 80401ed3733..703bbd6b184 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -609,7 +609,7 @@ pyfttt==0.3 pyharmony==1.0.16 # homeassistant.components.binary_sensor.hikvision -pyhik==0.1.3 +pyhik==0.1.4 # homeassistant.components.homematic pyhomematic==0.1.30 From 3f2eba09324b176fc4c105fd015f3c0fa821b423 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Sep 2017 00:51:52 -0700 Subject: [PATCH 146/277] Version bump to 0.53 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 88ab58201f8..e31a04aa291 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 53 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From ba310d3bd1ba9401bf49ac55717c70626074c151 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Sep 2017 00:52:47 -0700 Subject: [PATCH 147/277] Version bump to 0.54.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 88ab58201f8..1a92f0d68c9 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 = 53 +MINOR_VERSION = 54 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 160c7fc68509d87e66637f00279f88caf3f65b33 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 9 Sep 2017 19:20:48 +0200 Subject: [PATCH 148/277] Add HTTP Basic auth to RESTful Switch (#9162) * Add HTTP Basic auth to RESTful Switch * Remove redundant hass passing * Initialize to current state The state used to be None until the first periodic poll. This commit refactors async_update so it can be used during setup as well, allowing the state to start out with the correct value. * Refactor turn_on/turn_off device communication * Remove lint * Fix Travis errors --- homeassistant/components/switch/rest.py | 96 ++++++++++++++----------- tests/components/switch/test_rest.py | 4 +- 2 files changed, 57 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index 31d4f0f3e06..c0f75509425 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD) + CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD, CONF_USERNAME, + CONF_PASSWORD) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.template import Template @@ -41,6 +42,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(vol.Lower, vol.In(SUPPORT_REST_METHODS)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, }) @@ -53,8 +56,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): is_on_template = config.get(CONF_IS_ON_TEMPLATE) method = config.get(CONF_METHOD) name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) resource = config.get(CONF_RESOURCE) - websession = async_get_clientsession(hass) + + auth = None + if username: + auth = aiohttp.BasicAuth(username, password=password) if is_on_template is not None: is_on_template.hass = hass @@ -65,37 +73,32 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): timeout = config.get(CONF_TIMEOUT) try: - with async_timeout.timeout(timeout, loop=hass.loop): - req = yield from websession.get(resource) + switch = RestSwitch(name, resource, method, auth, body_on, body_off, + is_on_template, timeout) + req = yield from switch.get_device_state(hass) if req.status >= 400: _LOGGER.error("Got non-ok response from resource: %s", req.status) - return False - + else: + async_add_devices([switch]) except (TypeError, ValueError): _LOGGER.error("Missing resource or schema in configuration. " "Add http:// or https:// to your URL") - return False except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("No route to resource/endpoint: %s", resource) - return False - - async_add_devices( - [RestSwitch(hass, name, resource, method, body_on, body_off, - is_on_template, timeout)]) class RestSwitch(SwitchDevice): """Representation of a switch that can be toggled using REST.""" - def __init__(self, hass, name, resource, method, body_on, body_off, + def __init__(self, name, resource, method, auth, body_on, body_off, is_on_template, timeout): """Initialize the REST switch.""" self._state = None - self.hass = hass self._name = name self._resource = resource self._method = method + self._auth = auth self._body_on = body_on self._body_off = body_off self._is_on_template = is_on_template @@ -115,54 +118,61 @@ class RestSwitch(SwitchDevice): def async_turn_on(self, **kwargs): """Turn the device on.""" body_on_t = self._body_on.async_render() - websession = async_get_clientsession(self.hass) try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - request = yield from getattr(websession, self._method)( - self._resource, data=bytes(body_on_t, 'utf-8')) + req = yield from self.set_device_state(body_on_t) + + if req.status == 200: + self._state = True + else: + _LOGGER.error( + "Can't turn on %s. Is resource/endpoint offline?", + self._resource) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Error while turn on %s", self._resource) - return - - if request.status == 200: - self._state = True - else: - _LOGGER.error("Can't turn on %s. Is resource/endpoint offline?", - self._resource) @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the device off.""" body_off_t = self._body_off.async_render() - websession = async_get_clientsession(self.hass) try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - request = yield from getattr(websession, self._method)( - self._resource, data=bytes(body_off_t, 'utf-8')) + req = yield from self.set_device_state(body_off_t) + if req.status == 200: + self._state = False + else: + _LOGGER.error( + "Can't turn off %s. Is resource/endpoint offline?", + self._resource) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Error while turn off %s", self._resource) - return - if request.status == 200: - self._state = False - else: - _LOGGER.error("Can't turn off %s. Is resource/endpoint offline?", - self._resource) + @asyncio.coroutine + def set_device_state(self, body): + """Send a state update to the device.""" + websession = async_get_clientsession(self.hass) + + with async_timeout.timeout(self._timeout, loop=self.hass.loop): + req = yield from getattr(websession, self._method)( + self._resource, auth=self._auth, data=bytes(body, 'utf-8')) + return req @asyncio.coroutine def async_update(self): - """Get the latest data from REST API and update the state.""" - websession = async_get_clientsession(self.hass) - + """Get the current state, catching errors.""" try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - request = yield from websession.get(self._resource) - text = yield from request.text() + yield from self.get_device_state(self.hass) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.exception("Error while fetch data.") - return + + @asyncio.coroutine + def get_device_state(self, hass): + """Get the latest data from REST API and update the state.""" + websession = async_get_clientsession(hass) + + with async_timeout.timeout(self._timeout, loop=hass.loop): + req = yield from websession.get(self._resource, auth=self._auth) + text = yield from req.text() if self._is_on_template is not None: text = self._is_on_template.async_render_with_possible_json_value( @@ -181,3 +191,5 @@ class RestSwitch(SwitchDevice): self._state = False else: self._state = None + + return req diff --git a/tests/components/switch/test_rest.py b/tests/components/switch/test_rest.py index 97911fccbfd..1b8215660bd 100644 --- a/tests/components/switch/test_rest.py +++ b/tests/components/switch/test_rest.py @@ -99,11 +99,13 @@ class TestRestSwitch: self.name = 'foo' self.method = 'post' self.resource = 'http://localhost/' + self.auth = None self.body_on = Template('on', self.hass) self.body_off = Template('off', self.hass) self.switch = rest.RestSwitch( - self.hass, self.name, self.resource, self.method, self.body_on, + self.name, self.resource, self.method, self.auth, self.body_on, self.body_off, None, 10) + self.switch.hass = self.hass def teardown_method(self): """Stop everything that was started.""" From 7307ab878ad17c7e26748fcdb95c1ff53f5acb3c Mon Sep 17 00:00:00 2001 From: David Date: Mon, 11 Sep 2017 06:25:46 +1000 Subject: [PATCH 149/277] Bump pywebpush and pyJWT versions (#9355) * Update html5.py Bump pywebpush and PyJWT versions * Update requirements_all.txt * Update requirements_test_all.txt --- homeassistant/components/notify/html5.py | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 7151b418248..6b1cdf814fa 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -25,7 +25,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.frontend import add_manifest_json_key from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pywebpush==1.0.6', 'PyJWT==1.5.2'] +REQUIREMENTS = ['pywebpush==1.1.0', 'PyJWT==1.5.3'] DEPENDENCIES = ['frontend'] diff --git a/requirements_all.txt b/requirements_all.txt index 703bbd6b184..fce0300d7fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -21,7 +21,7 @@ astral==1.4 PyISY==1.0.8 # homeassistant.components.notify.html5 -PyJWT==1.5.2 +PyJWT==1.5.3 # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 @@ -827,7 +827,7 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.0.6 +pywebpush==1.1.0 # homeassistant.components.wemo pywemo==0.4.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7695f83497b..274b299347c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,7 +21,7 @@ freezegun>=0.3.8 # homeassistant.components.notify.html5 -PyJWT==1.5.2 +PyJWT==1.5.3 # homeassistant.components.media_player.sonos SoCo==0.12 @@ -111,7 +111,7 @@ python-forecastio==1.3.5 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.0.6 +pywebpush==1.1.0 # homeassistant.components.python_script restrictedpython==4.0a3 From e2fc9669f00f28b031eba0f4ccfc25d186fdb77e Mon Sep 17 00:00:00 2001 From: morberg Date: Mon, 11 Sep 2017 09:31:05 +0200 Subject: [PATCH 150/277] Add /usr/sbin to PATH (#9364) The `braviatv`platform needs the `arp` command to finalize configuration. This resides in `/usr/sbin`, at least on macOS 10.10. --- homeassistant/scripts/macos/launchd.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/scripts/macos/launchd.plist b/homeassistant/scripts/macos/launchd.plist index b65cdac7439..ba067387f55 100644 --- a/homeassistant/scripts/macos/launchd.plist +++ b/homeassistant/scripts/macos/launchd.plist @@ -8,7 +8,7 @@ EnvironmentVariables PATH - /usr/local/bin/:/usr/bin:$PATH + /usr/local/bin/:/usr/bin:/usr/sbin:$PATH Program From cc1979691e33609a59b4508499874f1986facca3 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Mon, 11 Sep 2017 20:30:48 +0200 Subject: [PATCH 151/277] Add polling interval service and setting available through zwave node entity panel (#9056) * Add polling interval to value panel * Blank lines removal * Update tests * Remove old config method * Raound 1 * Round 2 * Comment spacing * Expose value_id in attributes --- homeassistant/components/config/zwave.py | 1 + homeassistant/components/zwave/__init__.py | 36 ++++++++- homeassistant/components/zwave/const.py | 2 + homeassistant/components/zwave/services.yaml | 14 ++++ tests/components/config/test_zwave.py | 3 +- tests/components/zwave/test_init.py | 81 +++++++++++++++++++- 6 files changed, 132 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index a40e1f64043..53fa200a1b1 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -55,6 +55,7 @@ class ZWaveNodeValueView(HomeAssistantView): 'label': entity_values.primary.label, 'index': entity_values.primary.index, 'instance': entity_values.primary.instance, + 'poll_intensity': entity_values.primary.poll_intensity, } return self.json(values_data) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 853966279b6..c88c55e258f 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -85,6 +85,12 @@ SET_CONFIG_PARAMETER_SCHEMA = vol.Schema({ vol.Optional(const.ATTR_CONFIG_SIZE, default=2): vol.Coerce(int) }) +SET_POLL_INTENSITY_SCHEMA = vol.Schema({ + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int), + vol.Required(const.ATTR_POLL_INTENSITY): vol.Coerce(int), +}) + PRINT_CONFIG_PARAMETER_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int), @@ -415,6 +421,29 @@ def setup(hass, config): "Renamed Z-Wave value (Node %d Value %d) to %s", node_id, value_id, name) + def set_poll_intensity(service): + """Set the polling intensity of a node value.""" + node_id = service.data.get(const.ATTR_NODE_ID) + value_id = service.data.get(const.ATTR_VALUE_ID) + node = network.nodes[node_id] + value = node.values[value_id] + intensity = service.data.get(const.ATTR_POLL_INTENSITY) + if intensity == 0: + if value.disable_poll(): + _LOGGER.info("Polling disabled (Node %d Value %d)", + node_id, value_id) + return + _LOGGER.info("Polling disabled failed (Node %d Value %d)", + node_id, value_id) + else: + if value.enable_poll(intensity): + _LOGGER.info( + "Set polling intensity (Node %d Value %d) to %s", + node_id, value_id, intensity) + return + _LOGGER.info("Set polling intensity failed (Node %d Value %d)", + node_id, value_id) + def remove_failed_node(service): """Remove failed node.""" node_id = service.data.get(const.ATTR_NODE_ID) @@ -651,6 +680,10 @@ def setup(hass, config): descriptions[ const.SERVICE_RESET_NODE_METERS], schema=RESET_NODE_METERS_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_SET_POLL_INTENSITY, + set_poll_intensity, + descriptions[const.SERVICE_SET_POLL_INTENSITY], + schema=SET_POLL_INTENSITY_SCHEMA) # Setup autoheal if autoheal: @@ -775,8 +808,6 @@ class ZWaveDeviceEntityValues(): node_config.get(CONF_POLLING_INTENSITY), int) if polling_intensity: self.primary.enable_poll(polling_intensity) - else: - self.primary.disable_poll() platform = get_platform(component, DOMAIN) device = platform.get_device( @@ -887,6 +918,7 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): const.ATTR_NODE_ID: self.node_id, const.ATTR_VALUE_INDEX: self.values.primary.index, const.ATTR_VALUE_INSTANCE: self.values.primary.instance, + const.ATTR_VALUE_ID: str(self.values.primary.value_id), 'old_entity_id': self.old_entity_id, 'new_entity_id': self.new_entity_id, } diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index a238d01d520..dced1689dba 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -15,6 +15,7 @@ ATTR_BASIC_LEVEL = "basic_level" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_SIZE = "size" ATTR_CONFIG_VALUE = "value" +ATTR_POLL_INTENSITY = "poll_intensity" ATTR_VALUE_INDEX = "value_index" ATTR_VALUE_INSTANCE = "value_instance" NETWORK_READY_WAIT_SECS = 30 @@ -38,6 +39,7 @@ SERVICE_PRINT_CONFIG_PARAMETER = "print_config_parameter" SERVICE_PRINT_NODE = "print_node" SERVICE_REMOVE_FAILED_NODE = "remove_failed_node" SERVICE_REPLACE_FAILED_NODE = "replace_failed_node" +SERVICE_SET_POLL_INTENSITY = "set_poll_intensity" SERVICE_SET_WAKEUP = "set_wakeup" SERVICE_STOP_NETWORK = "stop_network" SERVICE_START_NETWORK = "start_network" diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index ea8a6eaa036..92b5fa25d20 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -56,6 +56,20 @@ set_config_parameter: size: description: (Optional) Set the size of the parameter value. Only needed if no parameters are available. +set_poll_intensity: + description: Set the polling interval to a nodes value + fields: + node_id: + description: ID of the node to set polling to. + example: 10 + value_id: + description: ID of the value to set polling to. + example: 72037594255792737 + poll_intensity: + description: The intensity to poll, 0 = disabled, 1 = Every time through list, 2 = Every second time through list... + example: 2 + + print_config_parameter: description: Prints a Z-Wave node config parameter value to log. fields: diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index ecf4d6ecb29..fc359dc7ff7 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -143,7 +143,7 @@ def test_get_values(hass, test_client): node = MockNode(node_id=1) value = MockValue(value_id=123456, node=node, label='Test Label', - instance=1, index=2) + instance=1, index=2, poll_intensity=4) values = MockEntityValues(primary=value) node2 = MockNode(node_id=2) value2 = MockValue(value_id=234567, node=node2, label='Test Label 2') @@ -162,6 +162,7 @@ def test_get_values(hass, test_client): 'label': 'Test Label', 'instance': 1, 'index': 2, + 'poll_intensity': 4, } } diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 2fa4dd0b929..1e759949a46 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -576,7 +576,6 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): assert args[3] == {const.DISCOVERY_DEVICE: id(values)} assert args[4] == self.zwave_config assert not self.primary.enable_poll.called - assert self.primary.disable_poll.called @patch.object(zwave, 'get_platform') @patch.object(zwave, 'discovery') @@ -742,7 +741,6 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): assert self.primary.enable_poll.called assert len(self.primary.enable_poll.mock_calls) == 1 assert self.primary.enable_poll.mock_calls[0][1][0] == 123 - assert not self.primary.disable_poll.called class TestZwave(unittest.TestCase): @@ -887,6 +885,85 @@ class TestZWaveServices(unittest.TestCase): assert value.label == "New Label" + def test_set_poll_intensity_enable(self): + """Test zwave set_poll_intensity service, succsessful set.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=0) + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 0 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 4, + }) + self.hass.block_till_done() + + enable_poll = value.enable_poll + assert value.enable_poll.called + assert len(enable_poll.mock_calls) == 2 + assert enable_poll.mock_calls[0][1][0] == 4 + + def test_set_poll_intensity_enable_failed(self): + """Test zwave set_poll_intensity service, failed set.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=0) + value.enable_poll.return_value = False + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 0 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 4, + }) + self.hass.block_till_done() + + enable_poll = value.enable_poll + assert value.enable_poll.called + assert len(enable_poll.mock_calls) == 1 + + def test_set_poll_intensity_disable(self): + """Test zwave set_poll_intensity service, successful disable.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=4) + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 4 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 0, + }) + self.hass.block_till_done() + + disable_poll = value.disable_poll + assert value.disable_poll.called + assert len(disable_poll.mock_calls) == 2 + + def test_set_poll_intensity_disable_failed(self): + """Test zwave set_poll_intensity service, failed disable.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=4) + value.disable_poll.return_value = False + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 4 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 0, + }) + self.hass.block_till_done() + + disable_poll = value.disable_poll + assert value.disable_poll.called + assert len(disable_poll.mock_calls) == 1 + def test_remove_failed_node(self): """Test zwave remove_failed_node service.""" self.hass.services.call('zwave', 'remove_failed_node', { From c7ecebfd07cc82ca6c3a40a8ec7e34b69cd64a60 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 12 Sep 2017 00:44:51 +0530 Subject: [PATCH 152/277] Round off probability to 2 decimals. (#9365) * Round off probablity to 2 decimals. * Update tests * remove debug print --- homeassistant/components/binary_sensor/bayesian.py | 3 +-- tests/components/binary_sensor/test_bayesian.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index 4c62735a6f9..ac328fd1f41 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -126,7 +126,6 @@ class BayesianBinarySensor(BinarySensorDevice): self.watchers[platform](entity_obs) prior = self.prior - print(self.current_obs.values()) for obs in self.current_obs.values(): prior = update_probability(prior, obs['prob_true'], obs['prob_false']) @@ -201,7 +200,7 @@ class BayesianBinarySensor(BinarySensorDevice): """Return the state attributes of the sensor.""" return { 'observations': [val for val in self.current_obs.values()], - 'probability': self.probability, + 'probability': round(self.probability, 2), 'probability_threshold': self._probability_threshold } diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py index f86047f3a3d..61b110f247f 100644 --- a/tests/components/binary_sensor/test_bayesian.py +++ b/tests/components/binary_sensor/test_bayesian.py @@ -73,7 +73,7 @@ class TestBayesianBinarySensor(unittest.TestCase): 'prob_false': 0.1, 'prob_true': 0.9 }], state.attributes.get('observations')) - self.assertAlmostEqual(0.7714285714285715, + self.assertAlmostEqual(0.77, state.attributes.get('probability')) assert state.state == 'on' @@ -141,7 +141,7 @@ class TestBayesianBinarySensor(unittest.TestCase): 'prob_true': 0.8, 'prob_false': 0.4 }], state.attributes.get('observations')) - self.assertAlmostEqual(0.33333333, state.attributes.get('probability')) + self.assertAlmostEqual(0.33, state.attributes.get('probability')) assert state.state == 'on' From 31f189da825de82388b5612237b056824ce197c0 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 11 Sep 2017 14:08:12 -0600 Subject: [PATCH 153/277] Added mqtt_statestream component (#9286) * Added mqtt_statestream component * Added tests for mqtt_statestream component * mqtt_statestream: add test for valid new_state * mqtt_statestream: Don't set initialized state * mqtt_statestream: Switch to using async_track_state_change * Cleanup --- homeassistant/components/mqtt_statestream.py | 45 ++++++++++++++ tests/components/test_mqtt_statestream.py | 65 ++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 homeassistant/components/mqtt_statestream.py create mode 100644 tests/components/test_mqtt_statestream.py diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py new file mode 100644 index 00000000000..76154e4ab58 --- /dev/null +++ b/homeassistant/components/mqtt_statestream.py @@ -0,0 +1,45 @@ +""" +Publish simple item state changes via MQTT. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt_statestream/ +""" +import asyncio + +import voluptuous as vol + +from homeassistant.const import MATCH_ALL +from homeassistant.core import callback +from homeassistant.components.mqtt import valid_publish_topic +from homeassistant.helpers.event import async_track_state_change + +CONF_BASE_TOPIC = 'base_topic' +DEPENDENCIES = ['mqtt'] +DOMAIN = 'mqtt_statestream' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_BASE_TOPIC): valid_publish_topic + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the MQTT state feed.""" + conf = config.get(DOMAIN, {}) + base_topic = conf.get(CONF_BASE_TOPIC) + if not base_topic.endswith('/'): + base_topic = base_topic + '/' + + @callback + def _state_publisher(entity_id, old_state, new_state): + if new_state is None: + return + payload = new_state.state + + topic = base_topic + entity_id.replace('.', '/') + hass.components.mqtt.async_publish(topic, payload, 1, True) + + async_track_state_change(hass, MATCH_ALL, _state_publisher) + return True diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py new file mode 100644 index 00000000000..73e2dbd1ac4 --- /dev/null +++ b/tests/components/test_mqtt_statestream.py @@ -0,0 +1,65 @@ +"""The tests for the MQTT statestream component.""" +from unittest.mock import patch + +from homeassistant.setup import setup_component +import homeassistant.components.mqtt_statestream as statestream +from homeassistant.core import State + +from tests.common import ( + get_test_home_assistant, + mock_mqtt_component, + mock_state_change_event +) + + +class TestMqttStateStream(object): + """Test the MQTT statestream module.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_mqtt = mock_mqtt_component(self.hass) + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + def add_statestream(self, base_topic=None): + """Add a mqtt_statestream component.""" + config = {} + if base_topic: + config['base_topic'] = base_topic + return setup_component(self.hass, statestream.DOMAIN, { + statestream.DOMAIN: config}) + + def test_fails_with_no_base(self): + """Setup should fail if no base_topic is set.""" + assert self.add_statestream() is False + + def test_setup_succeeds(self): + """"Test the success of the setup with a valid base_topic.""" + assert self.add_statestream(base_topic='pub') + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): + """"Test the sending of a new message if event changed.""" + e_id = 'fake.entity' + base_topic = 'pub' + + # Add the statestream component for publishing state updates + assert self.add_statestream(base_topic=base_topic) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State(e_id, 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity + mock_pub.assert_called_with(self.hass, 'pub/fake/entity', 'on', 1, + True) + assert mock_pub.called From 6d018386326378b76ccf9a7d3c03e9fcbded15c4 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 12 Sep 2017 02:27:40 +0530 Subject: [PATCH 154/277] Fixes #9379 - Added additional string check in Wunderground sensor (#9380) * Added additional string check * optimaze --- homeassistant/components/sensor/wunderground.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 3a72432610c..8f9a5ef1862 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -708,7 +708,7 @@ class WUndergroundSensor(Entity): def entity_picture(self): """Return the entity picture.""" url = self._cfg_expand("entity_picture") - if url is not None: + if isinstance(url, str): return re.sub(r'^http://', 'https://', url, flags=re.IGNORECASE) @property From 51ff6009a36f9488c2d8b47c01c6eac6a5b7c971 Mon Sep 17 00:00:00 2001 From: felix schwenzel Date: Mon, 11 Sep 2017 23:20:09 +0200 Subject: [PATCH 155/277] typo in waypoint import topic preventing waypoint import (#9338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit owntracks (tested on ios version 9.6.3/de_DE) publishes single waypoints under the topic owntracks///waypoint (singular). owntrack publishes a waypoint export (publish) under the topic owntracks///waypoints (plural). the owntracks component did not catch my waypoint export to mqtt, only single waypoint updates (i.e. after editing a waypoint or creating a new one). these single waypoints were rejected „because of missing or malformatted data“. when i changed the WAYPOINT_TOPIC to 'owntracks/{}/{}/waypoints', owntracks imported my published waypoint list, after i triggered it under Setting / Publish Waypoints. --- homeassistant/components/device_tracker/owntracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index b23008336ac..5c5c3c7c92e 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -42,7 +42,7 @@ VALIDATE_WAYPOINTS = 'waypoints' WAYPOINT_LAT_KEY = 'lat' WAYPOINT_LON_KEY = 'lon' -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint' +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), From 10c0744c4a30b65cb8f8e1e1bc42701a97fc24b0 Mon Sep 17 00:00:00 2001 From: Mike Christianson Date: Mon, 11 Sep 2017 21:37:36 -0700 Subject: [PATCH 156/277] Fixes #9353 (#9354) Follow [Twitter's guidance](https://dev.twitter.com/rest/reference/post/media/upload-finalize) for media uploads: "If and (only if) the response of the FINALIZE command contains a processing_info field, it may also be necessary to use a STATUS command and wait for it to return success before proceeding to Tweet creation." --- homeassistant/components/notify/twitter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 25e6fc00a2f..d4e969e95ec 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -116,6 +116,9 @@ class TwitterNotificationService(BaseNotificationService): self.log_error_resp(resp) return None + if resp.json().get('processing_info') is None: + return callback(media_id) + self.check_status_until_done(media_id, callback) def media_info(self, media_path): From 659dc2e55706a15444adf212b32cc047c708af26 Mon Sep 17 00:00:00 2001 From: viswa-swami Date: Tue, 12 Sep 2017 00:43:55 -0400 Subject: [PATCH 157/277] Fixing foscam library dependency/requirements (#9387) * Added support to enable/disable motion detection for foscam cameras. This support was added in 0.48.1 as a generic service for cameras. Motion detection can be enabled/disabled for foscam cameras with this code-set. * Fixed the violation identified by hound-bot * Fixed the comment posted by HoundCI-Bot regarding using imperative mood statement for pydocstyle * Fixed the error that travis-ci bot found. * As per comment from @balloob, Instead of directly using the URL to talk to foscam, used a 3rd party foscam library to communicate with it. This library already has support to enable/disable motion detection and also APIs to change the motion detection schedule etc. Need to add more support in the pyfoscam 3rd party library for checking if motion was detected or even if sound was detected. Once that is done, we can add that into HASS as well. * Lint * Removed the requests library import which is not used anymore * Updating requirements_all.txt based on the code-base of home assistant that i have. Generated using the gen_requirements_all.py script * Updating requirements_all.txt and requirements_test_all.txt generated by gen_requirements_all.py after latest pull from origin/dev * Updated requirements_all.txt with script * Updated the foscam camera code to fix lint errors * Fixed houndci violation * Updating the foscam library dependency/requirements. * Fixing the requirements_all file. Somehow when i generated, it generated duplicate entry for the same dependency --- homeassistant/components/camera/foscam.py | 4 ++-- requirements_all.txt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 8ea90d5a44e..3f2761e332a 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyfoscam==1.2'] +REQUIREMENTS = ['libpyfoscam==1.0'] CONF_IP = 'ip' @@ -53,7 +53,7 @@ class FoscamCam(Camera): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam.foscam import FoscamCamera + from libpyfoscam import FoscamCamera self._foscam_session = FoscamCamera(ip_address, port, self._username, self._password, verbose=False) diff --git a/requirements_all.txt b/requirements_all.txt index fce0300d7fd..0c57668201b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,6 +368,9 @@ libnacl==1.5.2 # homeassistant.components.dyson libpurecoollink==0.4.2 +# homeassistant.components.camera.foscam +libpyfoscam==1.0 + # homeassistant.components.device_tracker.mikrotik librouteros==1.0.2 @@ -599,9 +602,6 @@ pyfido==1.0.1 # homeassistant.components.climate.flexit pyflexit==0.3 -# homeassistant.components.camera.foscam -pyfoscam==1.2 - # homeassistant.components.ifttt pyfttt==0.3 From c84a099b0f7d2b3b5e62a14512d8324a64756167 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 11 Sep 2017 21:50:33 -0700 Subject: [PATCH 158/277] Update frontend --- homeassistant/components/frontend/version.py | 4 ++-- .../frontend/www_static/frontend.html | 9 +++++---- .../frontend/www_static/frontend.html.gz | Bin 167890 -> 168127 bytes .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-config.html | 4 ++-- .../www_static/panels/ha-panel-config.html.gz | Bin 32428 -> 34595 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5136 -> 5139 bytes 8 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 21215e14d23..87ccbf55075 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "c04709d3517dd3fd34b2f7d6bba6ec8e", + "frontend.html": "6b0a95408d9ee869d0fe20c374077ed4", "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "0091008947ed61a6691c28093a6a6fcd", + "panels/ha-panel-config.html": "0b985cbf668b16bca9f34727036c7139", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index d6a15a0d610..2dc0bb5f156 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,7 +8,7 @@ .flex-1{-ms-flex:1 1 0.000000001px;-webkit-flex:1;flex:1;-webkit-flex-basis:0.000000001px;flex-basis:0.000000001px;}.flex-2{-ms-flex:2;-webkit-flex:2;flex:2;}.flex-3{-ms-flex:3;-webkit-flex:3;flex:3;}.flex-4{-ms-flex:4;-webkit-flex:4;flex:4;}.flex-5{-ms-flex:5;-webkit-flex:5;flex:5;}.flex-6{-ms-flex:6;-webkit-flex:6;flex:6;}.flex-7{-ms-flex:7;-webkit-flex:7;flex:7;}.flex-8{-ms-flex:8;-webkit-flex:8;flex:8;}.flex-9{-ms-flex:9;-webkit-flex:9;flex:9;}.flex-10{-ms-flex:10;-webkit-flex:10;flex:10;}.flex-11{-ms-flex:11;-webkit-flex:11;flex:11;}.flex-12{-ms-flex:12;-webkit-flex:12;flex:12;} \ No newline at end of file + ha-script-editor{height:100%;} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 08a7f5002cd0f9d15d61a5e673e4c0d16885f183..66644926537bed6f7299bf68e979c03586fa7067 100644 GIT binary patch literal 34595 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 literal 32428 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 KGYA0z`o91X6$oDd diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index dc4770853e0..f23669fb07e 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -37,7 +37,7 @@ /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 'use strict'; -var precacheConfig = [["/","eceffe0debe81636e1eb8604e6eefbd6"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-c04709d3517dd3fd34b2f7d6bba6ec8e.html","e072f7bbe595bcb104d117a45592459d"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; +var precacheConfig = [["/","e22b4dfa3b4277935d374eb30b36b7a7"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-6b0a95408d9ee869d0fe20c374077ed4.html","2fced25e314a02654197adbfe36f1063"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 14edb98db2b161dd7c0cd15af893aa5f4be47e35..2b44cab0a33464c33265692beb6105eb242e2dfc 100644 GIT binary patch literal 5139 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 B_x%6> literal 5136 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 From 7f5c4cd1e521265d36aee0c0c831f70e22ae36b3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 11 Sep 2017 21:50:33 -0700 Subject: [PATCH 159/277] Update frontend --- homeassistant/components/frontend/version.py | 4 ++-- .../frontend/www_static/frontend.html | 9 +++++---- .../frontend/www_static/frontend.html.gz | Bin 167890 -> 168127 bytes .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-config.html | 4 ++-- .../www_static/panels/ha-panel-config.html.gz | Bin 32428 -> 34595 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5136 -> 5139 bytes 8 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 21215e14d23..87ccbf55075 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "c04709d3517dd3fd34b2f7d6bba6ec8e", + "frontend.html": "6b0a95408d9ee869d0fe20c374077ed4", "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "0091008947ed61a6691c28093a6a6fcd", + "panels/ha-panel-config.html": "0b985cbf668b16bca9f34727036c7139", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index d6a15a0d610..2dc0bb5f156 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,7 +8,7 @@ .flex-1{-ms-flex:1 1 0.000000001px;-webkit-flex:1;flex:1;-webkit-flex-basis:0.000000001px;flex-basis:0.000000001px;}.flex-2{-ms-flex:2;-webkit-flex:2;flex:2;}.flex-3{-ms-flex:3;-webkit-flex:3;flex:3;}.flex-4{-ms-flex:4;-webkit-flex:4;flex:4;}.flex-5{-ms-flex:5;-webkit-flex:5;flex:5;}.flex-6{-ms-flex:6;-webkit-flex:6;flex:6;}.flex-7{-ms-flex:7;-webkit-flex:7;flex:7;}.flex-8{-ms-flex:8;-webkit-flex:8;flex:8;}.flex-9{-ms-flex:9;-webkit-flex:9;flex:9;}.flex-10{-ms-flex:10;-webkit-flex:10;flex:10;}.flex-11{-ms-flex:11;-webkit-flex:11;flex:11;}.flex-12{-ms-flex:12;-webkit-flex:12;flex:12;} \ No newline at end of file + ha-script-editor{height:100%;} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 08a7f5002cd0f9d15d61a5e673e4c0d16885f183..66644926537bed6f7299bf68e979c03586fa7067 100644 GIT binary patch literal 34595 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 literal 32428 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 KGYA0z`o91X6$oDd diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index dc4770853e0..f23669fb07e 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -37,7 +37,7 @@ /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 'use strict'; -var precacheConfig = [["/","eceffe0debe81636e1eb8604e6eefbd6"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-c04709d3517dd3fd34b2f7d6bba6ec8e.html","e072f7bbe595bcb104d117a45592459d"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; +var precacheConfig = [["/","e22b4dfa3b4277935d374eb30b36b7a7"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-6b0a95408d9ee869d0fe20c374077ed4.html","2fced25e314a02654197adbfe36f1063"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 14edb98db2b161dd7c0cd15af893aa5f4be47e35..2b44cab0a33464c33265692beb6105eb242e2dfc 100644 GIT binary patch literal 5139 zcmV+u6zuCCiwFP!000021GQUwbK6Fe|6iYCth)^ly$95;h1h04R#L_IJNu&tUN2L-ua%sS;ZNdivcx-96*s)2B!5DLYS>n=G2m3uYPS zg#D7Hvskdp#2>J;IA-!w&N7jU>|O)|ETL6zB3~rA2-qqKM8=A_U}sD2K6mT|-rU$Avl z%q5)?3vt5!R^mv#f&-jm!4gkl6Oi$uWSV~FMX`K79In^v0hbI9(rh-26=*(ub9sJo zb$ihV)8(RfNi1?wz@MuqgTUT~@g28qv4RfzGhd~ zckBXuvb$d|Z`m&wR~I*DcNec2huB7mS!7%@zX6 z$=Q-;1sNJ%lre7=0ce?}J_H>QhD-T{C8brw1C|AnHBZd>eFR}kFPVu%WSq249v+dW zrP&ve^-AcxAbV#ie1*&eWWY~j#G4*i=s-^?>_wgm#uxmHVE!C-6;cplP1-EpNKOUZ1Da5p=Sp8m8JoNy zt>Xm|O2nFZymXpUfrKGl70Xr8WA_o4$Th~Ff$%Ly{Qk?LYfQx25}5ie!Aon;QpIQn zK9GQ1Rr-+~81=`fCJb3QC}(O0_YD`qm8WFUm( zh;nX%u;$RWAOYs-S{ls9Yt6kfj*1s47Hk&){GMk~y2`h84qYTgPS$zKL>@;;(HF^m zl@3bi5*UfC21D+ayFo;*1OHFb{wg80^g$|@&x=)DMBud#4=FZ%EE74o=tCdBzybO` zk7JMfU+^}^#w7ZlfRv&it`eUl?<3=hq{seTr3Ed8W*59qpcHvvr$d2CVc{2@)1xC$ zuDs?vO~Po#Ua$`zj)%v+V_{j|C>B ze1N!4C<5*}w&~d8X=rfad){F(+M!1vWBKOP6vA*!8wN0g(U?z0Vq%Ov9%@QlIDs|s zOn>Uzw&8|>?N7$;XcSIe!3{fngc9~*(RbX@G_V7Y8(_{h$EFiZr$!J?MPP{O*wJ)C zSYbTzJd=;Dk!ys$Z#bjLWIUdPzA(fiiqna%?^;vgcrd2xo2KnK1`OkdBW?!9*qmAw zDciLCscqU*!|+BU{ObAMH1Mq;v<=@GyZcEk__9Bqi~|_QHAjv)v5cwV!x2JvV)4*7 zV2Da-CbuVsF|urTJegXy4UY@4CI|u^K#Ph3DJb%XR9NB{FNz~XqdA#Q%!w1i#SCE> zW@t?8aO_WviDwCC6w#oWRMTot~Cl;c(sQ56VHIxkBn*H z3Nf9yfe{J|JUJu7aYQiEYO!F-z#0qN9C5>%j7O&H@W2a&Jqb<7*}}dEqW;u{6Tr<8 zdx+IB!aMZ17`bq9TTG{372J*k5g=3Oitz-z_*2skMgjc6MIum1L~{+M^I?{HX^{?Z zn1b|Yr1m}ez6 z?%Sc`y202(tdC815^~QRbHss|j%?F!VvA6BC@n$Gwjo&o;AdWw;;7jOqu1&Jk)YCK_i{=}X5VM|5Pg0}pj&$Hmu z0;=Jk5ceCAeez}{H{2dsQ!_A;*JvZL;mIQlthlxzCMM^{A3vVT-AHalS9~F`5p^1M zK>yzE_dDzv>*gX32hHuCy<|J@204l-!Je~D2aTlK=%QJIczc({Z+Qj+*!%N8c;U}! zYs9WM{QIg{d>Vcva0!pf{WZEdzeuyqB{uB`Kk>1Lhw=3yW_8>UTJMg&K8G*`6Ru_Q{Fqv7^f2wg~KHDf0JZm7Jci=Oy^=XOJg@H=Uuu#DHwn z)i&=cWLn*2qo#3nPsrYL)Yu!@3!#|LX@XOZ33#b5InHQ^U6F0!B1*lA5_&nP5<&NT zSAz0OuL7mSvf2l!K5PTtp?5zBOdm`5Z`y6z;Q^5faZYLGcdQdTD|X?^Z?z9P7NG zE$0xFw0}DheK!?HIn6lU&(TH4)PDvocU$ZC=&(H`gH=KMmBO=~n$ePKXfmN}8OwZ3 z+j&I2H`+xqg>LlMHQCxG)yqLFl36jA8<4S+kc(_3wsY<^C51KRIaw+rI|`bBk>#kr z#*SGIkmQ?kmZn@oW|b(Z-B^OaX4;W=;62odd8oib@OLgz)}p{!F-rht!&hjNUWL`B47)_K_}wL8d{=$X0*ISF`6PXMEo zhSWhbN%Dry7qNM9PeIgSYE+l&_2}tpOMbaOY3w4U<-Xi&A}>(l)l{Sh?qV*fvk&;v zWAf8S#Ywd_Xxd>!Bhv4Xf5e&t8X#`!^$IlDa=-?zCryY!A@Tw=KNugCST}Y2AzoDs zL5Qlhk={$?mJTo(c{K0SWc04r6!M1@{5g%1TED;HNvU^NiCVq8ir=BEkp9#}N?bqY z6&ibKN;xv6bdIH-DAqzKzJ4GD%K#={f2-3xIsKck;d2c;85WjdajJ)>BPSocgliIjNWWbs^KP zY??W>*Vcl1?6Hu?Rc&1=@_XAk6mBqJ-k)^i?k-Be1p+2car$^ZKq{dPC zT+o2?4Q&+(5=AFUP-w<+r&p!_t{j0Xtn$~vnn%SubP#b@5|YB_1N5j#w`zH}Icw;w z!Q6h$?prFq?}=>P7l7w(BO9Gj1P*A9-`(>5fec63>7=cC?E&qqrc0FVDw2}cLYI5A z&6Sa_&MbILofguHtSpN`3!$tXYnf=bs)=t?*KsG!Uk1;Oda4$oEkSJ1ARZeR`ixEv z{rA;w+o|*Amf}J|g@Sgt9j9%#&|3<5#iN+cz9_|&4x^?QD|L3frc=g((R3fWvJ8V= z|4v63`*6t(rgHEd2&pa2&LI7=k0S=vXXubgITu-$!i`HE$#WAbd#gNxI=IvTYBo;a z_Vzp-O;ROj)qF`zS-TDiPXjd${pI4W)98P1)rF5W?H%ZF^*RdS z;5~8=O|fJLT;3NVx(cd1-OXNVRPuzqc^Dp0Ex(8;)#aOn4y2QGmD5!TG>AYWPugb` zZK)iIN#q(^iJfMGbO*qf!_e|TS(WB0#X?#+U-U%X0IORpD$@n0&ABFx12Wa~k+|}* zi0T;X8B1QCU89-|Gd^3$BW8L+tQiNiYv*Z?&8TdA(&k7B z4h2yovORq|*b;BqbR~!7k2J!{soI2JY!~A)c2?U1uJ$4_JJqr%Q|E%V)ESSGp7Mtx zT9kWKN6ymaVO2+?LeC9TBj4SZ_mqP6A%MD`LIe=huljus7ZY|QG`Qv(Q&vo``6X^!_Z|M}iU3!;3DBvvN_t8uiHX2!J zd*zLhaztt1gT>HJ6NYIt9WW$zd;|o1~1Y1XSrMjJ(dyPA9 z^3vQgi)a9@+5qg_^=Pt1y4J=n(#kOZO69P#$EtbYwu)w|mO1&+x}O9%+!ItysscE? zf9pcMEYk0E!&D!_l{eR57*Q4&6pHc=W-=ReTD3v5X2tIBOG)efxXmZ+KD6P;V1dCu zuc$kaJ%Bn~G z?5}-}m#ch!e7JxPc)?FwWe~m5=AhpZwDkv>I&^gJEuq=IGx2%4zDf(axGP_DC8{DS zVx)9QPtWMu9-cJToYW2yV$*pGvChjh)NT4su~|a%$JmwxYNg<~c}!OD*>k37^I7u) zy(_=aAGOwd^~=HMb+J9qR5KcBh6AN%Qv}qp{+CCZ896Y*@69z5T_tEoq7Z?0t7a?H zT!m6Or88CeS#C~yV5D)ser1Qg$*rI@pHk8u>22YWbv0p$L@z(sca;^sOn_(TfwV=H zH9Kq6FzLF}J8I~@?JKOSV9ph$I-kh1uhYhZqNU$Z+;$@fW1O;x{TFLtDG#Rb<=L}l zhEgc7s0IBY`dG&+$*#Z#QduwL)<<<#CrtfSE@#LJU^_=w^9Q+FLU)^);Hdp~O-XG( z?fTt^^zLu1+3w+$68;l;i(X5V z4kQXLu{|$cQ9bpJ>O643uN8>QA$uUIjG|n&X6Q(!kwdYflFGMQ#vPuQd=sZU=rIB% z0q9>W$nj>b|JA^6@9*?XvxL@ZOm*$8w?y*vNEcbnFV$(TDyQVGo7G2t}}2itY9qWt$XWSk-Gex1iIUUbe@ zAe2S_E8%JRE5S1~9Gz!S`?nLdmhPuKXTRNEUk%jB5;)fiJE^Ar{|9-NvTOr2001Nb B_x%6> literal 5136 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 From 04dccb424644f80c27cb2bf985c663d4b1921c26 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 11 Sep 2017 21:53:01 -0700 Subject: [PATCH 160/277] Version bump to 0.53.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e31a04aa291..27ce35c7083 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 53 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From 202d4d8105856126e6de60e1afdcb33d5ad83fb3 Mon Sep 17 00:00:00 2001 From: Mike Christianson Date: Mon, 11 Sep 2017 21:37:36 -0700 Subject: [PATCH 161/277] Fixes #9353 (#9354) Follow [Twitter's guidance](https://dev.twitter.com/rest/reference/post/media/upload-finalize) for media uploads: "If and (only if) the response of the FINALIZE command contains a processing_info field, it may also be necessary to use a STATUS command and wait for it to return success before proceeding to Tweet creation." --- homeassistant/components/notify/twitter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 25e6fc00a2f..d4e969e95ec 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -116,6 +116,9 @@ class TwitterNotificationService(BaseNotificationService): self.log_error_resp(resp) return None + if resp.json().get('processing_info') is None: + return callback(media_id) + self.check_status_until_done(media_id, callback) def media_info(self, media_path): From 804d06d0d349560ec3621ed5ac9911e063e886fa Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 12 Sep 2017 02:27:40 +0530 Subject: [PATCH 162/277] Fixes #9379 - Added additional string check in Wunderground sensor (#9380) * Added additional string check * optimaze --- homeassistant/components/sensor/wunderground.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 3a72432610c..8f9a5ef1862 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -708,7 +708,7 @@ class WUndergroundSensor(Entity): def entity_picture(self): """Return the entity picture.""" url = self._cfg_expand("entity_picture") - if url is not None: + if isinstance(url, str): return re.sub(r'^http://', 'https://', url, flags=re.IGNORECASE) @property From 2a8620f806386a1997d050c44d204a31eeb3b05b Mon Sep 17 00:00:00 2001 From: viswa-swami Date: Tue, 12 Sep 2017 00:43:55 -0400 Subject: [PATCH 163/277] Fixing foscam library dependency/requirements (#9387) * Added support to enable/disable motion detection for foscam cameras. This support was added in 0.48.1 as a generic service for cameras. Motion detection can be enabled/disabled for foscam cameras with this code-set. * Fixed the violation identified by hound-bot * Fixed the comment posted by HoundCI-Bot regarding using imperative mood statement for pydocstyle * Fixed the error that travis-ci bot found. * As per comment from @balloob, Instead of directly using the URL to talk to foscam, used a 3rd party foscam library to communicate with it. This library already has support to enable/disable motion detection and also APIs to change the motion detection schedule etc. Need to add more support in the pyfoscam 3rd party library for checking if motion was detected or even if sound was detected. Once that is done, we can add that into HASS as well. * Lint * Removed the requests library import which is not used anymore * Updating requirements_all.txt based on the code-base of home assistant that i have. Generated using the gen_requirements_all.py script * Updating requirements_all.txt and requirements_test_all.txt generated by gen_requirements_all.py after latest pull from origin/dev * Updated requirements_all.txt with script * Updated the foscam camera code to fix lint errors * Fixed houndci violation * Updating the foscam library dependency/requirements. * Fixing the requirements_all file. Somehow when i generated, it generated duplicate entry for the same dependency --- homeassistant/components/camera/foscam.py | 4 ++-- requirements_all.txt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 8ea90d5a44e..3f2761e332a 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyfoscam==1.2'] +REQUIREMENTS = ['libpyfoscam==1.0'] CONF_IP = 'ip' @@ -53,7 +53,7 @@ class FoscamCam(Camera): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam.foscam import FoscamCamera + from libpyfoscam import FoscamCamera self._foscam_session = FoscamCamera(ip_address, port, self._username, self._password, verbose=False) diff --git a/requirements_all.txt b/requirements_all.txt index 703bbd6b184..7089ab28b64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,6 +368,9 @@ libnacl==1.5.2 # homeassistant.components.dyson libpurecoollink==0.4.2 +# homeassistant.components.camera.foscam +libpyfoscam==1.0 + # homeassistant.components.device_tracker.mikrotik librouteros==1.0.2 @@ -599,9 +602,6 @@ pyfido==1.0.1 # homeassistant.components.climate.flexit pyflexit==0.3 -# homeassistant.components.camera.foscam -pyfoscam==1.2 - # homeassistant.components.ifttt pyfttt==0.3 From 90f9a6bc0ab05ce4f633a4309bde326b16a44bac Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 12 Sep 2017 10:01:03 +0200 Subject: [PATCH 164/277] Cleanup and simplitfy the async state update (#9390) * Cleanup and simplitfy the async state update * Update test_entity.py --- .../alarm_control_panel/alarmdecoder.py | 8 +++---- .../alarm_control_panel/envisalink.py | 2 +- .../components/alarm_control_panel/mqtt.py | 2 +- homeassistant/components/android_ip_webcam.py | 2 +- .../components/binary_sensor/alarmdecoder.py | 4 ++-- .../components/binary_sensor/envisalink.py | 2 +- .../components/binary_sensor/ffmpeg_motion.py | 2 +- .../components/binary_sensor/mqtt.py | 2 +- .../components/binary_sensor/mystrom.py | 2 +- .../components/binary_sensor/template.py | 2 +- .../components/climate/generic_thermostat.py | 2 +- homeassistant/components/cover/knx.py | 2 +- homeassistant/components/cover/mqtt.py | 12 +++++----- homeassistant/components/cover/template.py | 16 +++++++------- homeassistant/components/eight_sleep.py | 4 ++-- homeassistant/components/fan/mqtt.py | 10 ++++----- homeassistant/components/ffmpeg.py | 2 +- homeassistant/components/light/mqtt.py | 18 +++++++-------- homeassistant/components/light/mqtt_json.py | 6 ++--- .../components/light/mqtt_template.py | 6 ++--- homeassistant/components/light/template.py | 8 +++---- homeassistant/components/light/zha.py | 6 ++--- homeassistant/components/lock/mqtt.py | 6 ++--- homeassistant/components/mailbox/__init__.py | 2 +- .../components/media_player/apple_tv.py | 4 ++-- homeassistant/components/media_player/emby.py | 2 +- homeassistant/components/media_player/kodi.py | 8 +++---- .../components/media_player/snapcast.py | 10 ++++----- .../components/media_player/universal.py | 2 +- homeassistant/components/mysensors.py | 2 +- homeassistant/components/plant.py | 2 +- homeassistant/components/rflink.py | 2 +- .../components/sensor/alarmdecoder.py | 2 +- homeassistant/components/sensor/arwn.py | 2 +- homeassistant/components/sensor/envisalink.py | 2 +- homeassistant/components/sensor/mqtt.py | 4 ++-- homeassistant/components/sensor/mqtt_room.py | 2 +- homeassistant/components/sensor/otp.py | 2 +- homeassistant/components/sensor/template.py | 4 ++-- homeassistant/components/sensor/time_date.py | 2 +- homeassistant/components/sensor/torque.py | 2 +- homeassistant/components/sun.py | 4 ++-- .../components/switch/android_ip_webcam.py | 4 ++-- homeassistant/components/switch/mqtt.py | 8 +++---- homeassistant/components/switch/template.py | 4 ++-- homeassistant/helpers/entity.py | 6 ++++- tests/helpers/test_entity.py | 22 +++++++++++++++++++ 47 files changed, 128 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index f54774b8923..3b58eb0b71d 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -57,19 +57,19 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): if message.alarm_sounding or message.fire_alarm: if self._state != STATE_ALARM_TRIGGERED: self._state = STATE_ALARM_TRIGGERED - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() elif message.armed_away: if self._state != STATE_ALARM_ARMED_AWAY: self._state = STATE_ALARM_ARMED_AWAY - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() elif message.armed_home: if self._state != STATE_ALARM_ARMED_HOME: self._state = STATE_ALARM_ARMED_HOME - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() else: if self._state != STATE_ALARM_DISARMED: self._state = STATE_ALARM_DISARMED - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def name(self): diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index f6d388a6c5b..026d2324ed3 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -106,7 +106,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): def _update_callback(self, partition): """Update Home Assistant state, if needed.""" if partition is None or int(partition) == self._partition_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def code_format(self): diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 33bfe464eea..fca935388c1 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -87,7 +87,7 @@ class MqttAlarm(alarm.AlarmControlPanel): _LOGGER.warning("Received unexpected payload: %s", payload) return self._state = payload - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index 2fb039f0ab3..2883fca9ab6 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -263,7 +263,7 @@ class AndroidIPCamEntity(Entity): """Update callback.""" if self._host != host: return - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index 495feaf64ab..bc05e4d84d8 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -102,11 +102,11 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 5fbc1eb90a1..7d35c0c9e94 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -80,4 +80,4 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): def _update_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant/components/binary_sensor/ffmpeg_motion.py index 1bbf39dd6e0..47b1be988bf 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_motion.py +++ b/homeassistant/components/binary_sensor/ffmpeg_motion.py @@ -73,7 +73,7 @@ class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice): def _async_callback(self, state): """HA-FFmpeg callback for noise detection.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def is_on(self): diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 3702b32d586..7d40544d601 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -86,7 +86,7 @@ class MqttBinarySensor(BinarySensorDevice): elif payload == self._payload_off: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) diff --git a/homeassistant/components/binary_sensor/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py index 08ab1f4a8b7..2afaa032745 100644 --- a/homeassistant/components/binary_sensor/mystrom.py +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -92,4 +92,4 @@ class MyStromBinarySensor(BinarySensorDevice): def async_on_update(self, value): """Receive an update.""" self._state = value - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 413804f0856..84afd01303f 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -161,7 +161,7 @@ class BinarySensorTemplate(BinarySensorDevice): def set_state(): """Set state of template binary sensor.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # state without delay if (state and not self._delay_on) or \ diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 9442b7da194..6af06323fd0 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -211,7 +211,7 @@ class GenericThermostat(ClimateDevice): """Handle heater switch state changes.""" if new_state is None: return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def _async_keep_alive(self, time): diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index e4c2931983d..296d8d36394 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -215,7 +215,7 @@ class KNXCover(CoverDevice): def auto_updater_hook(self, now): """Callback for autoupdater.""" # pylint: disable=unused-argument - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self.device.position_reached(): self.stop_auto_updater() diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index eab64fd7abb..8e197cc2e02 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -178,7 +178,7 @@ class MqttCover(CoverDevice): level = self.find_percentage_in_range(float(payload)) self._tilt_value = level - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def message_received(topic, payload, qos): @@ -203,7 +203,7 @@ class MqttCover(CoverDevice): payload) return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. @@ -275,7 +275,7 @@ class MqttCover(CoverDevice): if self._optimistic: # Optimistically assume that cover has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -289,7 +289,7 @@ class MqttCover(CoverDevice): if self._optimistic: # Optimistically assume that cover has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -309,7 +309,7 @@ class MqttCover(CoverDevice): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_open_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -319,7 +319,7 @@ class MqttCover(CoverDevice): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_closed_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index f9e059d3927..2e3ad7fff16 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -197,7 +197,7 @@ class CoverTemplate(CoverDevice): @callback def template_cover_state_listener(entity, old_state, new_state): """Handle target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_cover_startup(event): @@ -205,7 +205,7 @@ class CoverTemplate(CoverDevice): async_track_state_change( self.hass, self._entities, template_cover_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_cover_startup) @@ -271,7 +271,7 @@ class CoverTemplate(CoverDevice): yield from self._position_script.async_run({"position": 100}) if self._optimistic: self._position = 100 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -282,7 +282,7 @@ class CoverTemplate(CoverDevice): yield from self._position_script.async_run({"position": 0}) if self._optimistic: self._position = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -297,7 +297,7 @@ class CoverTemplate(CoverDevice): yield from self._position_script.async_run( {"position": self._position}) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_open_cover_tilt(self, **kwargs): @@ -305,7 +305,7 @@ class CoverTemplate(CoverDevice): self._tilt_value = 100 yield from self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -314,7 +314,7 @@ class CoverTemplate(CoverDevice): yield from self._tilt_script.async_run( {"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): @@ -322,7 +322,7 @@ class CoverTemplate(CoverDevice): self._tilt_value = kwargs[ATTR_TILT_POSITION] yield from self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 40a5d884aed..dda556ba6a4 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -209,7 +209,7 @@ class EightSleepUserEntity(Entity): @callback def async_eight_user_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_USER, async_eight_user_update) @@ -233,7 +233,7 @@ class EightSleepHeatEntity(Entity): @callback def async_eight_heat_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index bc732aa0aff..58ac08ce16f 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -160,7 +160,7 @@ class MqttFan(FanEntity): self._state = True elif payload == self._payload[STATE_OFF]: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -177,7 +177,7 @@ class MqttFan(FanEntity): self._speed = SPEED_MEDIUM elif payload == self._payload[SPEED_HIGH]: self._speed = SPEED_HIGH - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -193,7 +193,7 @@ class MqttFan(FanEntity): self._oscillation = True elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: self._oscillation = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -287,7 +287,7 @@ class MqttFan(FanEntity): if self._optimistic_speed: self._speed = speed - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_oscillate(self, oscillating: bool) -> None: @@ -309,4 +309,4 @@ class MqttFan(FanEntity): if self._optimistic_oscillation: self._oscillation = oscillating - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 887d07e5855..f5efa1ef623 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -242,7 +242,7 @@ class FFmpegBase(Entity): def async_start_handle(event): """Start FFmpeg process.""" yield from self._async_start_ffmpeg(None) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, async_start_handle) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index ac72a7052f1..a66cecd3ef8 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -220,7 +220,7 @@ class MqttLight(Light): self._state = True elif payload == self._payload['off']: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -233,7 +233,7 @@ class MqttLight(Light): device_value = float(templates[CONF_BRIGHTNESS](payload)) percent_bright = device_value / self._brightness_scale self._brightness = int(percent_bright * 255) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -250,7 +250,7 @@ class MqttLight(Light): """Handle new MQTT messages for RGB.""" self._rgb = [int(val) for val in templates[CONF_RGB](payload).split(',')] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -266,7 +266,7 @@ class MqttLight(Light): def color_temp_received(topic, payload, qos): """Handle new MQTT messages for color temperature.""" self._color_temp = int(templates[CONF_COLOR_TEMP](payload)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -282,7 +282,7 @@ class MqttLight(Light): def effect_received(topic, payload, qos): """Handle new MQTT messages for effect.""" self._effect = templates[CONF_EFFECT](payload) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -300,7 +300,7 @@ class MqttLight(Light): device_value = float(templates[CONF_WHITE_VALUE](payload)) percent_white = device_value / self._white_value_scale self._white_value = int(percent_white * 255) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -317,7 +317,7 @@ class MqttLight(Light): """Handle new MQTT messages for color.""" self._xy = [float(val) for val in templates[CONF_XY](payload).split(',')] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -483,7 +483,7 @@ class MqttLight(Light): should_update = True if should_update: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -498,4 +498,4 @@ class MqttLight(Light): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index 4fee1138909..5663e1fc50d 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -226,7 +226,7 @@ class MqttJson(Light): except ValueError: _LOGGER.warning("Invalid XY color value received") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -373,7 +373,7 @@ class MqttJson(Light): should_update = True if should_update: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -393,4 +393,4 @@ class MqttJson(Light): if self._optimistic: # Optimistically assume that the light has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py index 07fd6d45d8c..6dabedbd444 100755 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -211,7 +211,7 @@ class MqttTemplate(Light): else: _LOGGER.warning("Unsupported effect value received") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -323,7 +323,7 @@ class MqttTemplate(Light): ) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -345,7 +345,7 @@ class MqttTemplate(Light): ) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def supported_features(self): diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 07703d6c067..f630625746e 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -155,7 +155,7 @@ class LightTemplate(Light): @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)) + self.async_schedule_update_ha_state(True) @callback def template_light_startup(event): @@ -165,7 +165,7 @@ class LightTemplate(Light): async_track_state_change( self.hass, self._entities, template_light_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_light_startup) @@ -192,7 +192,7 @@ class LightTemplate(Light): self.hass.async_add_job(self._on_script.async_run()) if optimistic_set: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -200,7 +200,7 @@ class LightTemplate(Light): 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()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index e7ba394a977..a18fdc9dec6 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -105,19 +105,19 @@ class Light(zha.Entity, light.Light): duration ) self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return yield from self._endpoint.on_off.on() self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the entity off.""" yield from self._endpoint.on_off.off() self._state = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def brightness(self): diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index de14d21a09b..b2533145a20 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -93,7 +93,7 @@ class MqttLock(LockDevice): elif payload == self._payload_unlock: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. @@ -134,7 +134,7 @@ class MqttLock(LockDevice): if self._optimistic: # Optimistically assume that switch has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_unlock(self, **kwargs): @@ -148,4 +148,4 @@ class MqttLock(LockDevice): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 9d731016035..21b2dc7279f 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -111,7 +111,7 @@ class MailboxEntity(Entity): @callback def _mailbox_updated(event): - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) hass.bus.async_listen(EVENT, _mailbox_updated) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 3ecb1c0922e..5deb4cd8dd5 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -112,7 +112,7 @@ class AppleTvDevice(MediaPlayerDevice): def playstatus_update(self, updater, playing): """Print what is currently playing when it changes.""" self._playing = playing - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def playstatus_error(self, updater, exception): @@ -126,7 +126,7 @@ class AppleTvDevice(MediaPlayerDevice): # implemented here later. updater.start(initial_delay=10) self._playing = None - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def media_content_type(self): diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index 8df6bc4fd1b..ebb8a670488 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -159,7 +159,7 @@ class EmbyDevice(MediaPlayerDevice): self.media_status_last_position = None self.media_status_received = None - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def hidden(self): diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index a51238e9aaf..00dd90938c8 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -325,7 +325,7 @@ class KodiDevice(MediaPlayerDevice): # If a new item is playing, force a complete refresh force_refresh = data['item']['id'] != self._item.get('id') - self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + self.async_schedule_update_ha_state(force_refresh) @callback def async_on_stop(self, sender, data): @@ -337,14 +337,14 @@ class KodiDevice(MediaPlayerDevice): self._players = [] self._properties = {} self._item = {} - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def async_on_volume_changed(self, sender, data): """Handle the volume changes.""" self._app_properties['volume'] = data['volume'] self._app_properties['muted'] = data['muted'] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def async_on_quit(self, sender, data): @@ -403,7 +403,7 @@ class KodiDevice(MediaPlayerDevice): # to reconnect on the next poll. pass # Update HA state after Kodi disconnects - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Create a task instead of adding a tracking job, since this task will # run until the websocket connection is closed. diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 1715f0f1829..3f1607831e5 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -159,19 +159,19 @@ class SnapcastGroupDevice(MediaPlayerDevice): 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()) + self.async_schedule_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()) + self.async_schedule_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()) + self.async_schedule_update_ha_state() def snapshot(self): """Snapshot the group state.""" @@ -235,13 +235,13 @@ class SnapcastClientDevice(MediaPlayerDevice): def async_mute_volume(self, mute): """Send the mute command.""" yield from self._client.set_muted(mute) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_volume_level(self, volume): """Set the volume level.""" yield from self._client.set_volume(round(volume * 100)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() def snapshot(self): """Snapshot the client state.""" diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index daf874a31dd..e25f9d18252 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -148,7 +148,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): @callback def async_on_dependency_update(*_): """Update ha state when dependencies update.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) depend = copy(children) for entity in attributes.values(): diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index c37116fb32d..71be416c59c 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -637,7 +637,7 @@ class MySensorsEntity(MySensorsDevice, Entity): def _async_update_callback(self): """Update the entity.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @asyncio.coroutine def async_added_to_hass(self): diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 9b9e11e0fbb..3a6876e3e12 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -212,7 +212,7 @@ class Plant(Entity): self._icon = 'mdi:thumb-up' self._problems = PROBLEM_NONE _LOGGER.debug("New data processed") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def should_poll(self): diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index fe3e954c571..74e533d70ec 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -272,7 +272,7 @@ class RflinkDevice(Entity): self._handle_event(event) # Propagate changes through ha - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Put command onto bus for user to subscribe to if self._should_fire_event and identify_event_type( diff --git a/homeassistant/components/sensor/alarmdecoder.py b/homeassistant/components/sensor/alarmdecoder.py index dba1697f026..6b026298db0 100644 --- a/homeassistant/components/sensor/alarmdecoder.py +++ b/homeassistant/components/sensor/alarmdecoder.py @@ -50,7 +50,7 @@ class AlarmDecoderSensor(Entity): def _message_callback(self, message): if self._display != message.text: self._display = message.text - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/sensor/arwn.py b/homeassistant/components/sensor/arwn.py index 4aa8e20cb75..6fd09874651 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -123,7 +123,7 @@ class ArwnSensor(Entity): """Update the sensor with the most recent event.""" self.event = {} self.event.update(event) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def state(self): diff --git a/homeassistant/components/sensor/envisalink.py b/homeassistant/components/sensor/envisalink.py index 7f1ee5c0d41..24cb224570c 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -77,4 +77,4 @@ class EnvisalinkSensor(EnvisalinkDevice, Entity): def _update_callback(self, partition): """Update the partition state in HA, if needed.""" if partition is None or int(partition) == self._partition_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 63b015b3dfd..70b1294c13f 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -100,7 +100,7 @@ class MqttSensor(Entity): payload = self._template.async_render_with_possible_json_value( payload, self._state) self._state = payload - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) @@ -110,7 +110,7 @@ class MqttSensor(Entity): """Triggered when value is expired.""" self._expiration_trigger = None self._state = STATE_UNKNOWN - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def should_poll(self): diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index 3d0dbd68afa..e14922a1579 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -96,7 +96,7 @@ class MQTTRoomSensor(Entity): self._distance = distance self._updated = dt.utcnow() - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def message_received(topic, payload, qos): diff --git a/homeassistant/components/sensor/otp.py b/homeassistant/components/sensor/otp.py index 5d7808ea4c7..6ceed11a6b9 100644 --- a/homeassistant/components/sensor/otp.py +++ b/homeassistant/components/sensor/otp.py @@ -62,7 +62,7 @@ class TOTPSensor(Entity): @callback def _call_loop(self): self._state = self._otp.now() - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Update must occur at even TIME_STEP, e.g. 12:00:00, 12:00:30, # 12:01:00, etc. in order to have synced time (see RFC6238) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index fdd0ef9c2ad..e59864dea2b 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -100,7 +100,7 @@ class SensorTemplate(Entity): @callback def template_sensor_state_listener(entity, old_state, new_state): """Handle device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_sensor_startup(event): @@ -108,7 +108,7 @@ class SensorTemplate(Entity): async_track_state_change( self.hass, self._entities, template_sensor_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_sensor_startup) diff --git a/homeassistant/components/sensor/time_date.py b/homeassistant/components/sensor/time_date.py index a59ee01bac2..69723aea19a 100644 --- a/homeassistant/components/sensor/time_date.py +++ b/homeassistant/components/sensor/time_date.py @@ -129,6 +129,6 @@ class TimeDateSensor(Entity): 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()) + self.async_schedule_update_ha_state() async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.get_next_interval()) diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index 3ce277f794b..98fad475d52 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -141,4 +141,4 @@ class TorqueSensor(Entity): def async_on_update(self, value): """Receive an update.""" self._state = value - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index 9d3d82bd8fc..90c7f69e64a 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -126,7 +126,7 @@ class Sun(Entity): """Run when the state of the sun has changed.""" self.update_sun_position(now) self.update_as_of(now) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Schedule next update at next_change+1 second so sun state has changed async_track_point_in_utc_time( @@ -137,4 +137,4 @@ class Sun(Entity): def timer_update(self, time): """Needed to update solar elevation and azimuth.""" self.update_sun_position(time) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/android_ip_webcam.py b/homeassistant/components/switch/android_ip_webcam.py index 8c8f04b6161..df86b3fbb8f 100644 --- a/homeassistant/components/switch/android_ip_webcam.py +++ b/homeassistant/components/switch/android_ip_webcam.py @@ -72,7 +72,7 @@ class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): else: yield from self._ipcam.change_setting(self._setting, True) self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -86,7 +86,7 @@ class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): else: yield from self._ipcam.change_setting(self._setting, False) self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 308cce4de46..21820b4a015 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -111,7 +111,7 @@ class MqttSwitch(SwitchDevice): elif payload == self._payload_off: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def availability_message_received(topic, payload, qos): @@ -121,7 +121,7 @@ class MqttSwitch(SwitchDevice): elif payload == self._payload_not_available: self._available = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. @@ -173,7 +173,7 @@ class MqttSwitch(SwitchDevice): if self._optimistic: # Optimistically assume that switch has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -187,4 +187,4 @@ class MqttSwitch(SwitchDevice): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index fc076f32e88..9b73d668c8c 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -103,7 +103,7 @@ class SwitchTemplate(SwitchDevice): @callback def template_switch_state_listener(entity, old_state, new_state): """Handle target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_switch_startup(event): @@ -111,7 +111,7 @@ class SwitchTemplate(SwitchDevice): async_track_state_change( self.hass, self._entities, template_switch_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_switch_startup) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 49f250c65fa..835b616987c 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -297,10 +297,14 @@ class Entity(object): def schedule_update_ha_state(self, force_refresh=False): """Schedule a update ha state change task. - That is only needed on executor to not block. + That avoid executor dead looks. """ self.hass.add_job(self.async_update_ha_state(force_refresh)) + def async_schedule_update_ha_state(self, force_refresh=False): + """Schedule a update ha state change task.""" + self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + def remove(self) -> None: """Remove entity from HASS.""" run_coroutine_threadsafe( diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 644c8894874..cf73e066072 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -191,3 +191,25 @@ def test_warn_slow_update_with_exception(hass): assert mock_call().cancel.called assert update_call + + +@asyncio.coroutine +def test_async_schedule_update_ha_state(hass): + """Warn we log when entity update takes a long time and trow exception.""" + update_call = False + + @asyncio.coroutine + def async_update(): + """Mock async update.""" + nonlocal update_call + update_call = True + + mock_entity = entity.Entity() + mock_entity.hass = hass + mock_entity.entity_id = 'comp_test.test_entity' + mock_entity.async_update = async_update + + mock_entity.async_schedule_update_ha_state(True) + yield from hass.async_block_till_done() + + assert update_call is True From c9fc3fae6e21d9facb3be27e6974b83c8d6aa4e9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Sep 2017 09:47:04 -0700 Subject: [PATCH 165/277] Update cloud auth (#9357) * Update cloud logic * Lint * Update test requirements * Address commments, fix tests * Add credentials --- homeassistant/components/cloud/__init__.py | 8 +- homeassistant/components/cloud/auth_api.py | 270 +++++++++++++++ homeassistant/components/cloud/cloud_api.py | 297 ----------------- homeassistant/components/cloud/const.py | 10 +- homeassistant/components/cloud/http_api.py | 229 +++++++++---- homeassistant/components/http/__init__.py | 46 ++- requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 61 ++-- tests/components/cloud/test_auth_api.py | 271 +++++++++++++++ tests/components/cloud/test_cloud_api.py | 352 -------------------- tests/components/cloud/test_http_api.py | 319 ++++++++++++++---- 12 files changed, 1047 insertions(+), 822 deletions(-) create mode 100644 homeassistant/components/cloud/auth_api.py delete mode 100644 homeassistant/components/cloud/cloud_api.py create mode 100644 tests/components/cloud/test_auth_api.py delete mode 100644 tests/components/cloud/test_cloud_api.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8804f6d113f..44796f97166 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -4,10 +4,11 @@ import logging import voluptuous as vol -from . import http_api, cloud_api +from . import http_api, auth_api from .const import DOMAIN +REQUIREMENTS = ['warrant==0.2.0'] DEPENDENCIES = ['http'] CONF_MODE = 'mode' MODE_DEV = 'development' @@ -40,10 +41,7 @@ def async_setup(hass, config): 'mode': mode } - cloud = yield from cloud_api.async_load_auth(hass) - - if cloud is not None: - data['cloud'] = cloud + data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass) yield from http_api.async_setup(hass) return True diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py new file mode 100644 index 00000000000..0baadeece46 --- /dev/null +++ b/homeassistant/components/cloud/auth_api.py @@ -0,0 +1,270 @@ +"""Package to offer tools to authenticate with the cloud.""" +import json +import logging +import os + +from .const import AUTH_FILE, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UserNotFound(CloudError): + """Raised when a user is not found.""" + + +class UserNotConfirmed(CloudError): + """Raised when a user has not confirmed email yet.""" + + +class ExpiredCode(CloudError): + """Raised when an expired code is encoutered.""" + + +class InvalidCode(CloudError): + """Raised when an invalid code is submitted.""" + + +class PasswordChangeRequired(CloudError): + """Raised when a password change is required.""" + + def __init__(self, message='Password change required.'): + """Initialize a password change required error.""" + super().__init__(message) + + +class UnknownError(CloudError): + """Raised when an unknown error occurrs.""" + + +AWS_EXCEPTIONS = { + 'UserNotFoundException': UserNotFound, + 'NotAuthorizedException': Unauthenticated, + 'ExpiredCodeException': ExpiredCode, + 'UserNotConfirmedException': UserNotConfirmed, + 'PasswordResetRequiredException': PasswordChangeRequired, + 'CodeMismatchException': InvalidCode, +} + + +def _map_aws_exception(err): + """Map AWS exception to our exceptions.""" + ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) + return ex(err.response['Error']['Message']) + + +def load_auth(hass): + """Load authentication from disk and verify it.""" + info = _read_info(hass) + + if info is None: + return Auth(hass) + + auth = Auth(hass, _cognito( + hass, + id_token=info['id_token'], + access_token=info['access_token'], + refresh_token=info['refresh_token'], + )) + + if auth.validate_auth(): + return auth + + return Auth(hass) + + +def register(hass, email, password): + """Register a new account.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.register(email, password) + except ClientError as err: + raise _map_aws_exception(err) + + +def confirm_register(hass, confirmation_code, email): + """Confirm confirmation code after registration.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.confirm_sign_up(confirmation_code, email) + except ClientError as err: + raise _map_aws_exception(err) + + +def forgot_password(hass, email): + """Initiate forgotten password flow.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.initiate_forgot_password() + except ClientError as err: + raise _map_aws_exception(err) + + +def confirm_forgot_password(hass, confirmation_code, email, new_password): + """Confirm forgotten password code and change password.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.confirm_forgot_password(confirmation_code, new_password) + except ClientError as err: + raise _map_aws_exception(err) + + +class Auth(object): + """Class that holds Cloud authentication.""" + + def __init__(self, hass, cognito=None): + """Initialize Hass cloud info object.""" + self.hass = hass + self.cognito = cognito + self.account = None + + @property + def is_logged_in(self): + """Return if user is logged in.""" + return self.account is not None + + def validate_auth(self): + """Validate that the contained auth is valid.""" + from botocore.exceptions import ClientError + + try: + self._refresh_account_info() + except ClientError as err: + if err.response['Error']['Code'] != 'NotAuthorizedException': + _LOGGER.error('Unexpected error verifying auth: %s', err) + return False + + try: + self.renew_access_token() + self._refresh_account_info() + except ClientError: + _LOGGER.error('Unable to refresh auth token: %s', err) + return False + + return True + + def login(self, username, password): + """Login using a username and password.""" + from botocore.exceptions import ClientError + from warrant.exceptions import ForceChangePasswordException + + cognito = _cognito(self.hass, username=username) + + try: + cognito.authenticate(password=password) + self.cognito = cognito + self._refresh_account_info() + _write_info(self.hass, self) + + except ForceChangePasswordException as err: + raise PasswordChangeRequired + + except ClientError as err: + raise _map_aws_exception(err) + + def _refresh_account_info(self): + """Refresh the account info. + + Raises boto3 exceptions. + """ + self.account = self.cognito.get_user() + + def renew_access_token(self): + """Refresh token.""" + from botocore.exceptions import ClientError + + try: + self.cognito.renew_access_token() + _write_info(self.hass, self) + return True + except ClientError as err: + _LOGGER.error('Error refreshing token: %s', err) + return False + + def logout(self): + """Invalidate token.""" + from botocore.exceptions import ClientError + + try: + self.cognito.logout() + self.account = None + _write_info(self.hass, self) + except ClientError as err: + raise _map_aws_exception(err) + + +def _read_info(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_info(hass, auth): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if auth.is_logged_in: + content[mode] = { + 'id_token': auth.cognito.id_token, + 'access_token': auth.cognito.access_token, + 'refresh_token': auth.cognito.refresh_token, + } + else: + content.pop(mode, None) + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _cognito(hass, **kwargs): + """Get the client credentials.""" + from warrant import Cognito + + mode = get_mode(hass) + + info = SERVERS.get(mode) + + if info is None: + raise ValueError('Mode {} is not supported.'.format(mode)) + + cognito = Cognito( + user_pool_id=info['identity_pool_id'], + client_id=info['client_id'], + user_pool_region=info['region'], + access_key=info['access_key_id'], + secret_key=info['secret_access_key'], + **kwargs + ) + + return cognito diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py deleted file mode 100644 index 6429da14516..00000000000 --- a/homeassistant/components/cloud/cloud_api.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Package to offer tools to communicate with the cloud.""" -import asyncio -from datetime import timedelta -import json -import logging -import os -from urllib.parse import urljoin - -import aiohttp -import async_timeout - -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.dt import utcnow - -from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS -from .util import get_mode - -_LOGGER = logging.getLogger(__name__) - - -URL_CREATE_TOKEN = 'o/token/' -URL_REVOKE_TOKEN = 'o/revoke_token/' -URL_ACCOUNT = 'account.json' - - -class CloudError(Exception): - """Base class for cloud related errors.""" - - def __init__(self, reason=None, status=None): - """Initialize a cloud error.""" - super().__init__(reason) - self.status = status - - -class Unauthenticated(CloudError): - """Raised when authentication failed.""" - - -class UnknownError(CloudError): - """Raised when an unknown error occurred.""" - - -@asyncio.coroutine -def async_load_auth(hass): - """Load authentication from disk and verify it.""" - auth = yield from hass.async_add_job(_read_auth, hass) - - if not auth: - return None - - cloud = Cloud(hass, auth) - - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - auth_check = yield from cloud.async_refresh_account_info() - - if not auth_check: - _LOGGER.error('Unable to validate credentials.') - return None - - return cloud - - except asyncio.TimeoutError: - _LOGGER.error('Unable to reach server to validate credentials.') - return None - - -@asyncio.coroutine -def async_login(hass, username, password, scope=None): - """Get a token using a username and password. - - Returns a coroutine. - """ - data = { - 'grant_type': 'password', - 'username': username, - 'password': password - } - if scope is not None: - data['scope'] = scope - - auth = yield from _async_get_token(hass, data) - - yield from hass.async_add_job(_write_auth, hass, auth) - - return Cloud(hass, auth) - - -@asyncio.coroutine -def _async_get_token(hass, data): - """Get a new token and return it as a dictionary. - - Raises exceptions when errors occur: - - Unauthenticated - - UnknownError - """ - session = async_get_clientsession(hass) - auth = aiohttp.BasicAuth(*_client_credentials(hass)) - - try: - req = yield from session.post( - _url(hass, URL_CREATE_TOKEN), - data=data, - auth=auth - ) - - if req.status == 401: - _LOGGER.error('Cloud login failed: %d', req.status) - raise Unauthenticated(status=req.status) - elif req.status != 200: - _LOGGER.error('Cloud login failed: %d', req.status) - raise UnknownError(status=req.status) - - response = yield from req.json() - response['expires_at'] = \ - (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() - - return response - - except aiohttp.ClientError: - raise UnknownError() - - -class Cloud: - """Store Hass Cloud info.""" - - def __init__(self, hass, auth): - """Initialize Hass cloud info object.""" - self.hass = hass - self.auth = auth - self.account = None - - @property - def access_token(self): - """Return access token.""" - return self.auth['access_token'] - - @property - def refresh_token(self): - """Get refresh token.""" - return self.auth['refresh_token'] - - @asyncio.coroutine - def async_refresh_account_info(self): - """Refresh the account info.""" - req = yield from self.async_request('get', URL_ACCOUNT) - - if req.status != 200: - return False - - self.account = yield from req.json() - return True - - @asyncio.coroutine - def async_refresh_access_token(self): - """Get a token using a refresh token.""" - try: - self.auth = yield from _async_get_token(self.hass, { - 'grant_type': 'refresh_token', - 'refresh_token': self.refresh_token, - }) - - yield from self.hass.async_add_job( - _write_auth, self.hass, self.auth) - - return True - except CloudError: - return False - - @asyncio.coroutine - def async_revoke_access_token(self): - """Revoke active access token.""" - session = async_get_clientsession(self.hass) - client_id, client_secret = _client_credentials(self.hass) - data = { - 'token': self.access_token, - 'client_id': client_id, - 'client_secret': client_secret - } - try: - req = yield from session.post( - _url(self.hass, URL_REVOKE_TOKEN), - data=data, - ) - - if req.status != 200: - _LOGGER.error('Cloud logout failed: %d', req.status) - raise UnknownError(status=req.status) - - self.auth = None - yield from self.hass.async_add_job( - _write_auth, self.hass, None) - - except aiohttp.ClientError: - raise UnknownError() - - @asyncio.coroutine - def async_request(self, method, path, **kwargs): - """Make a request to Home Assistant cloud. - - Will refresh the token if necessary. - """ - session = async_get_clientsession(self.hass) - url = _url(self.hass, path) - - if 'headers' not in kwargs: - kwargs['headers'] = {} - - kwargs['headers']['authorization'] = \ - 'Bearer {}'.format(self.access_token) - - request = yield from session.request(method, url, **kwargs) - - if request.status != 403: - return request - - # Maybe token expired. Try refreshing it. - reauth = yield from self.async_refresh_access_token() - - if not reauth: - return request - - # Release old connection back to the pool. - yield from request.release() - - kwargs['headers']['authorization'] = \ - 'Bearer {}'.format(self.access_token) - - # If we are not already fetching the account info, - # refresh the account info. - - if path != URL_ACCOUNT: - yield from self.async_refresh_account_info() - - request = yield from session.request(method, url, **kwargs) - - return request - - -def _read_auth(hass): - """Read auth file.""" - path = hass.config.path(AUTH_FILE) - - if not os.path.isfile(path): - return None - - with open(path) as file: - return json.load(file).get(get_mode(hass)) - - -def _write_auth(hass, data): - """Write auth info for specified mode. - - Pass in None for data to remove authentication for that mode. - """ - path = hass.config.path(AUTH_FILE) - mode = get_mode(hass) - - if os.path.isfile(path): - with open(path) as file: - content = json.load(file) - else: - content = {} - - if data is None: - content.pop(mode, None) - else: - content[mode] = data - - with open(path, 'wt') as file: - file.write(json.dumps(content, indent=4, sort_keys=True)) - - -def _client_credentials(hass): - """Get the client credentials. - - Async friendly. - """ - mode = get_mode(hass) - - if mode not in SERVERS: - raise ValueError('Mode {} is not supported.'.format(mode)) - - return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] - - -def _url(hass, path): - """Generate a url for the cloud. - - Async friendly. - """ - mode = get_mode(hass) - - if mode not in SERVERS: - raise ValueError('Mode {} is not supported.'.format(mode)) - - return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index f55a4be21a2..81beab1891b 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -5,10 +5,10 @@ AUTH_FILE = '.cloud' SERVERS = { 'development': { - 'host': 'http://localhost:8000', - 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', - 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' - 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' - 'VBJrRyfgTVd43kbrEQtuOiaUpK') + 'client_id': '3k755iqfcgv8t12o4pl662mnos', + 'identity_pool_id': 'us-west-2_vDOfweDJo', + 'region': 'us-west-2', + 'access_key_id': 'AKIAJGRK7MILPRJTT2ZQ', + 'secret_access_key': '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' } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 661cc8a7ba1..941df7648a6 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,14 +1,16 @@ """The HTTP api to control the cloud integration.""" import asyncio +from functools import wraps import logging import voluptuous as vol import async_timeout -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import ( + HomeAssistantView, RequestDataValidator) -from . import cloud_api -from .const import DOMAIN, REQUEST_TIMEOUT +from . import auth_api +from .const import REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -19,6 +21,42 @@ def async_setup(hass): hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) hass.http.register_view(CloudAccountView) + hass.http.register_view(CloudRegisterView) + hass.http.register_view(CloudConfirmRegisterView) + hass.http.register_view(CloudForgotPasswordView) + hass.http.register_view(CloudConfirmForgotPasswordView) + + +_CLOUD_ERRORS = { + auth_api.UserNotFound: (400, "User does not exist."), + auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), + auth_api.Unauthenticated: (401, 'Authentication failed.'), + auth_api.PasswordChangeRequired: (400, 'Password change required.'), + auth_api.ExpiredCode: (400, 'Confirmation code has expired.'), + auth_api.InvalidCode: (400, 'Invalid confirmation code.'), + asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') +} + + +def _handle_cloud_errors(handler): + """Helper method to handle auth errors.""" + @asyncio.coroutine + @wraps(handler) + def error_handler(view, request, *args, **kwargs): + """Handle exceptions that raise from the wrapped request handler.""" + try: + result = yield from handler(view, request, *args, **kwargs) + return result + + except (auth_api.CloudError, asyncio.TimeoutError) as err: + err_info = _CLOUD_ERRORS.get(err.__class__) + if err_info is None: + err_info = (502, 'Unexpected error: {}'.format(err)) + status, msg = err_info + return view.json_message(msg, status_code=status, + message_code=err.__class__.__name__) + + return error_handler class CloudLoginView(HomeAssistantView): @@ -26,52 +64,23 @@ class CloudLoginView(HomeAssistantView): url = '/api/cloud/login' name = 'api:cloud:login' - schema = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str, - }) @asyncio.coroutine - def post(self, request): - """Validate config and return results.""" - try: - data = yield from request.json() - except ValueError: - _LOGGER.error('Login with invalid JSON') - return self.json_message('Invalid JSON.', 400) - - try: - self.schema(data) - except vol.Invalid as err: - _LOGGER.error('Login with invalid formatted data') - return self.json_message( - 'Message format incorrect: {}'.format(err), 400) - + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): str, + })) + def post(self, request, data): + """Handle login request.""" hass = request.app['hass'] - phase = 1 - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - cloud = yield from cloud_api.async_login( - hass, data['username'], data['password']) + auth = hass.data['cloud']['auth'] - phase += 1 + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.login, data['email'], + data['password']) - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from cloud.async_refresh_account_info() - - except cloud_api.Unauthenticated: - return self.json_message( - 'Authentication failed (phase {}).'.format(phase), 401) - except cloud_api.UnknownError: - return self.json_message( - 'Unknown error occurred (phase {}).'.format(phase), 500) - except asyncio.TimeoutError: - return self.json_message( - 'Unable to reach Home Assistant cloud ' - '(phase {}).'.format(phase), 502) - - hass.data[DOMAIN]['cloud'] = cloud - return self.json(cloud.account) + return self.json(_auth_data(auth)) class CloudLogoutView(HomeAssistantView): @@ -81,39 +90,133 @@ class CloudLogoutView(HomeAssistantView): name = 'api:cloud:logout' @asyncio.coroutine + @_handle_cloud_errors def post(self, request): - """Validate config and return results.""" + """Handle logout request.""" hass = request.app['hass'] - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from \ - hass.data[DOMAIN]['cloud'].async_revoke_access_token() + auth = hass.data['cloud']['auth'] - hass.data[DOMAIN].pop('cloud') + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.logout) - return self.json({ - 'result': 'ok', - }) - except asyncio.TimeoutError: - return self.json_message("Could not reach the server.", 502) - except cloud_api.UnknownError as err: - return self.json_message( - "Error communicating with the server ({}).".format(err.status), - 502) + return self.json_message('ok') class CloudAccountView(HomeAssistantView): - """Log out of the Home Assistant cloud.""" + """View to retrieve account info.""" url = '/api/cloud/account' name = 'api:cloud:account' @asyncio.coroutine def get(self, request): - """Validate config and return results.""" + """Get account info.""" hass = request.app['hass'] + auth = hass.data['cloud']['auth'] - if 'cloud' not in hass.data[DOMAIN]: + if not auth.is_logged_in: return self.json_message('Not logged in', 400) - return self.json(hass.data[DOMAIN]['cloud'].account) + return self.json(_auth_data(auth)) + + +class CloudRegisterView(HomeAssistantView): + """Register on the Home Assistant cloud.""" + + url = '/api/cloud/register' + name = 'api:cloud:register' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): vol.All(str, vol.Length(min=6)), + })) + def post(self, request, data): + """Handle registration request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.register, hass, data['email'], data['password']) + + return self.json_message('ok') + + +class CloudConfirmRegisterView(HomeAssistantView): + """Confirm registration on the Home Assistant cloud.""" + + url = '/api/cloud/confirm_register' + name = 'api:cloud:confirm_register' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('confirmation_code'): str, + vol.Required('email'): str, + })) + def post(self, request, data): + """Handle registration confirmation request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.confirm_register, hass, data['confirmation_code'], + data['email']) + + return self.json_message('ok') + + +class CloudForgotPasswordView(HomeAssistantView): + """View to start Forgot Password flow..""" + + url = '/api/cloud/forgot_password' + name = 'api:cloud:forgot_password' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + def post(self, request, data): + """Handle forgot password request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.forgot_password, hass, data['email']) + + return self.json_message('ok') + + +class CloudConfirmForgotPasswordView(HomeAssistantView): + """View to finish Forgot Password flow..""" + + url = '/api/cloud/confirm_forgot_password' + name = 'api:cloud:confirm_forgot_password' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('confirmation_code'): str, + vol.Required('email'): str, + vol.Required('new_password'): vol.All(str, vol.Length(min=6)) + })) + def post(self, request, data): + """Handle forgot password confirm request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.confirm_forgot_password, hass, + data['confirmation_code'], data['email'], + data['new_password']) + + return self.json_message('ok') + + +def _auth_data(auth): + """Generate the auth data JSON response.""" + return { + 'email': auth.account.email + } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index d8647dea0c3..c444cf1abbf 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/http/ """ import asyncio import json +from functools import wraps import logging import ssl from ipaddress import ip_network @@ -364,9 +365,12 @@ class HomeAssistantView(object): return web.Response( body=msg, content_type=CONTENT_TYPE_JSON, status=status_code) - def json_message(self, error, status_code=200): + def json_message(self, message, status_code=200, message_code=None): """Return a JSON message response.""" - return self.json({'message': error}, status_code) + data = {'message': message} + if message_code is not None: + data['code'] = message_code + return self.json(data, status_code) @asyncio.coroutine # pylint: disable=no-self-use @@ -443,3 +447,41 @@ def request_handler_factory(view, handler): return web.Response(body=result, status=status_code) return handle + + +class RequestDataValidator: + """Decorator that will validate the incoming data. + + Takes in a voluptuous schema and adds 'post_data' as + keyword argument to the function call. + + Will return a 400 if no JSON provided or doesn't match schema. + """ + + def __init__(self, schema): + """Initialize the decorator.""" + self._schema = schema + + def __call__(self, method): + """Decorate a function.""" + @asyncio.coroutine + @wraps(method) + def wrapper(view, request, *args, **kwargs): + """Wrap a request handler with data validation.""" + try: + data = yield from request.json() + except ValueError: + _LOGGER.error('Invalid JSON received.') + return view.json_message('Invalid JSON.', 400) + + try: + kwargs['data'] = self._schema(data) + except vol.Invalid as err: + _LOGGER.error('Data does not match schema: %s', err) + return view.json_message( + 'Message format incorrect: {}'.format(err), 400) + + result = yield from method(view, request, *args, **kwargs) + return result + + return wrapper diff --git a/requirements_all.txt b/requirements_all.txt index 0c57668201b..a8b92b45d59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -999,6 +999,9 @@ wakeonlan==0.2.2 # homeassistant.components.sensor.waqi waqiasync==1.0.0 +# homeassistant.components.cloud +warrant==0.2.0 + # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 274b299347c..ea09ebbc648 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -141,5 +141,8 @@ statsd==3.2.1 # homeassistant.components.camera.uvc uvcclient==0.10.0 +# homeassistant.components.cloud +warrant==0.2.0 + # homeassistant.components.sensor.yahoo_finance yahoo-finance==1.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 8a215cd2873..99bcf80288b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -33,44 +33,45 @@ COMMENT_REQUIREMENTS = ( ) TEST_REQUIREMENTS = ( - 'pydispatch', - 'influxdb', - 'nx584', - 'uvcclient', - 'somecomfort', 'aioautomatic', - 'SoCo', - 'libsoundtouch', - 'libpurecoollink', - 'rxv', - 'apns2', - 'sqlalchemy', - 'forecastio', 'aiohttp_cors', - 'pilight', + 'apns2', + 'dsmr_parser', + 'ephem', + 'evohomeclient', + 'forecastio', 'fuzzywuzzy', + 'gTTS-token', + 'ha-ffmpeg', + 'hbmqtt', + 'holidays', + 'influxdb', + 'libpurecoollink', + 'libsoundtouch', + 'mficlient', + 'nx584', + 'paho', + 'pexpect', + 'pilight', + 'pmsensor', + 'prometheus_client', + 'pydispatch', + 'PyJWT', + 'pylitejet', + 'pyunifi', + 'pywebpush', + 'restrictedpython', 'rflink', 'ring_doorbell', + 'rxv', 'sleepyq', + 'SoCo', + 'somecomfort', + 'sqlalchemy', 'statsd', - 'pylitejet', - 'holidays', - 'evohomeclient', - 'pexpect', - 'hbmqtt', - 'paho', - 'dsmr_parser', - 'mficlient', - 'pmsensor', + 'uvcclient', + 'warrant', 'yahoo-finance', - 'ha-ffmpeg', - 'gTTS-token', - 'pywebpush', - 'PyJWT', - 'restrictedpython', - 'pyunifi', - 'prometheus_client', - 'ephem' ) IGNORE_PACKAGES = ( diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py new file mode 100644 index 00000000000..652829d2f32 --- /dev/null +++ b/tests/components/cloud/test_auth_api.py @@ -0,0 +1,271 @@ +"""Tests for the tools to communicate with the cloud.""" +from unittest.mock import MagicMock, patch + +from botocore.exceptions import ClientError +import pytest + +from homeassistant.components.cloud import DOMAIN, auth_api + + +MOCK_AUTH = { + "id_token": "fake_id_token", + "access_token": "fake_access_token", + "refresh_token": "fake_refresh_token", +} + + +@pytest.fixture +def cloud_hass(hass): + """Fixture to return a hass instance with cloud mode set.""" + hass.data[DOMAIN] = {'mode': 'development'} + return hass + + +@pytest.fixture +def mock_write(): + """Mock reading authentication.""" + with patch.object(auth_api, '_write_info') as mock: + yield mock + + +@pytest.fixture +def mock_read(): + """Mock writing authentication.""" + with patch.object(auth_api, '_read_info') as mock: + yield mock + + +@pytest.fixture +def mock_cognito(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: + yield mock_cog() + + +@pytest.fixture +def mock_auth(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api.Auth') as mock_auth: + yield mock_auth() + + +def aws_error(code, message='Unknown', operation_name='fake_operation_name'): + """Generate AWS error response.""" + response = { + 'Error': { + 'Code': code, + 'Message': message + } + } + return ClientError(response, operation_name) + + +def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): + """Test loading authentication with no stored auth.""" + mock_read.return_value = None + auth = auth_api.load_auth(cloud_hass) + assert auth.cognito is None + + +def test_load_auth_with_invalid_auth(cloud_hass, mock_read, mock_cognito): + """Test calling load_auth when auth is no longer valid.""" + mock_cognito.get_user.side_effect = aws_error('SomeError') + auth = auth_api.load_auth(cloud_hass) + + assert auth.cognito is None + + +def test_load_auth_with_valid_auth(cloud_hass, mock_read, mock_cognito): + """Test calling load_auth when valid auth.""" + auth = auth_api.load_auth(cloud_hass) + + assert auth.cognito is not None + + +def test_auth_properties(): + """Test Auth class properties.""" + auth = auth_api.Auth(None, None) + assert not auth.is_logged_in + auth.account = {} + assert auth.is_logged_in + + +def test_auth_validate_auth_verification_fails(mock_cognito): + """Test validate authentication with verify request failing.""" + mock_cognito.get_user.side_effect = aws_error('UserNotFoundException') + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is False + + +def test_auth_validate_auth_token_refresh_needed_fails(mock_cognito): + """Test validate authentication with refresh needed which gets 401.""" + mock_cognito.get_user.side_effect = aws_error('NotAuthorizedException') + mock_cognito.renew_access_token.side_effect = \ + aws_error('NotAuthorizedException') + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is False + + +def test_auth_validate_auth_token_refresh_needed_succeeds(mock_write, + mock_cognito): + """Test validate authentication with refresh.""" + mock_cognito.get_user.side_effect = [ + aws_error('NotAuthorizedException'), + MagicMock(email='hello@home-assistant.io') + ] + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is True + assert len(mock_write.mock_calls) == 1 + + +def test_auth_login_invalid_auth(mock_cognito, mock_write): + """Test trying to login with invalid credentials.""" + mock_cognito.authenticate.side_effect = aws_error('NotAuthorizedException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.Unauthenticated): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login_user_not_found(mock_cognito, mock_write): + """Test trying to login with invalid credentials.""" + mock_cognito.authenticate.side_effect = aws_error('UserNotFoundException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.UserNotFound): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login_user_not_confirmed(mock_cognito, mock_write): + """Test trying to login without confirming account.""" + mock_cognito.authenticate.side_effect = \ + aws_error('UserNotConfirmedException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.UserNotConfirmed): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login(cloud_hass, mock_cognito, mock_write): + """Test trying to login without confirming account.""" + mock_cognito.get_user.return_value = \ + MagicMock(email='hello@home-assistant.io') + auth = auth_api.Auth(cloud_hass, None) + auth.login('user', 'pass') + assert auth.is_logged_in + assert len(mock_cognito.authenticate.mock_calls) == 1 + assert len(mock_write.mock_calls) == 1 + result_hass, result_auth = mock_write.mock_calls[0][1] + assert result_hass is cloud_hass + assert result_auth is auth + + +def test_auth_renew_access_token(mock_write, mock_cognito): + """Test renewing an access token.""" + auth = auth_api.Auth(None, mock_cognito) + assert auth.renew_access_token() + assert len(mock_write.mock_calls) == 1 + + +def test_auth_renew_access_token_fails(mock_write, mock_cognito): + """Test failing to renew an access token.""" + mock_cognito.renew_access_token.side_effect = aws_error('SomeError') + auth = auth_api.Auth(None, mock_cognito) + assert not auth.renew_access_token() + assert len(mock_write.mock_calls) == 0 + + +def test_auth_logout(mock_write, mock_cognito): + """Test renewing an access token.""" + auth = auth_api.Auth(None, mock_cognito) + auth.account = MagicMock() + auth.logout() + assert auth.account is None + assert len(mock_write.mock_calls) == 1 + + +def test_auth_logout_fails(mock_write, mock_cognito): + """Test error while logging out.""" + mock_cognito.logout.side_effect = aws_error('SomeError') + auth = auth_api.Auth(None, mock_cognito) + auth.account = MagicMock() + with pytest.raises(auth_api.CloudError): + auth.logout() + assert auth.account is not None + assert len(mock_write.mock_calls) == 0 + + +def test_register(mock_cognito): + """Test registering an account.""" + auth_api.register(None, 'email@home-assistant.io', 'password') + assert len(mock_cognito.register.mock_calls) == 1 + result_email, result_password = mock_cognito.register.mock_calls[0][1] + assert result_email == 'email@home-assistant.io' + assert result_password == 'password' + + +def test_register_fails(mock_cognito): + """Test registering an account.""" + mock_cognito.register.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.register(None, 'email@home-assistant.io', 'password') + + +def test_confirm_register(mock_cognito): + """Test confirming a registration of an account.""" + auth_api.confirm_register(None, '123456', 'email@home-assistant.io') + assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 + result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] + assert result_email == 'email@home-assistant.io' + assert result_code == '123456' + + +def test_confirm_register_fails(mock_cognito): + """Test an error during confirmation of an account.""" + mock_cognito.confirm_sign_up.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.confirm_register(None, '123456', 'email@home-assistant.io') + + +def test_forgot_password(mock_cognito): + """Test starting forgot password flow.""" + auth_api.forgot_password(None, 'email@home-assistant.io') + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + + +def test_forgot_password_fails(mock_cognito): + """Test failure when starting forgot password flow.""" + mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.forgot_password(None, 'email@home-assistant.io') + + +def test_confirm_forgot_password(mock_cognito): + """Test confirming forgot password.""" + auth_api.confirm_forgot_password( + None, '123456', 'email@home-assistant.io', 'new password') + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 + result_code, result_password = \ + mock_cognito.confirm_forgot_password.mock_calls[0][1] + assert result_code == '123456' + assert result_password == 'new password' + + +def test_confirm_forgot_password_fails(mock_cognito): + """Test failure when confirming forgot password.""" + mock_cognito.confirm_forgot_password.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.confirm_forgot_password( + None, '123456', 'email@home-assistant.io', 'new password') diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py deleted file mode 100644 index 11c396daf05..00000000000 --- a/tests/components/cloud/test_cloud_api.py +++ /dev/null @@ -1,352 +0,0 @@ -"""Tests for the tools to communicate with the cloud.""" -import asyncio -from datetime import timedelta -from unittest.mock import patch -from urllib.parse import urljoin - -import aiohttp -import pytest - -from homeassistant.components.cloud import DOMAIN, cloud_api, const -import homeassistant.util.dt as dt_util - -from tests.common import mock_coro - - -MOCK_AUTH = { - "access_token": "jvCHxpTu2nfORLBRgQY78bIAoK4RPa", - "expires_at": "2017-08-29T05:33:28.266048+00:00", - "expires_in": 86400, - "refresh_token": "C4wR1mgb03cs69EeiFgGOBC8mMQC5Q", - "scope": "", - "token_type": "Bearer" -} - - -def url(path): - """Create a url.""" - return urljoin(const.SERVERS['development']['host'], path) - - -@pytest.fixture -def cloud_hass(hass): - """Fixture to return a hass instance with cloud mode set.""" - hass.data[DOMAIN] = {'mode': 'development'} - return hass - - -@pytest.fixture -def mock_write(): - """Mock reading authentication.""" - with patch.object(cloud_api, '_write_auth') as mock: - yield mock - - -@pytest.fixture -def mock_read(): - """Mock writing authentication.""" - with patch.object(cloud_api, '_read_auth') as mock: - yield mock - - -@asyncio.coroutine -def test_async_login_invalid_auth(cloud_hass, aioclient_mock, mock_write): - """Test trying to login with invalid credentials.""" - aioclient_mock.post(url('o/token/'), status=401) - with pytest.raises(cloud_api.Unauthenticated): - yield from cloud_api.async_login(cloud_hass, 'user', 'pass') - - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_async_login_cloud_error(cloud_hass, aioclient_mock, mock_write): - """Test exception in cloud while logging in.""" - aioclient_mock.post(url('o/token/'), status=500) - with pytest.raises(cloud_api.UnknownError): - yield from cloud_api.async_login(cloud_hass, 'user', 'pass') - - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_async_login_client_error(cloud_hass, aioclient_mock, mock_write): - """Test client error while logging in.""" - aioclient_mock.post(url('o/token/'), exc=aiohttp.ClientError) - with pytest.raises(cloud_api.UnknownError): - yield from cloud_api.async_login(cloud_hass, 'user', 'pass') - - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_async_login(cloud_hass, aioclient_mock, mock_write): - """Test logging in.""" - aioclient_mock.post(url('o/token/'), json={ - 'expires_in': 10 - }) - now = dt_util.utcnow() - with patch('homeassistant.components.cloud.cloud_api.utcnow', - return_value=now): - yield from cloud_api.async_login(cloud_hass, 'user', 'pass') - - assert len(mock_write.mock_calls) == 1 - result_hass, result_data = mock_write.mock_calls[0][1] - assert result_hass is cloud_hass - assert result_data == { - 'expires_in': 10, - 'expires_at': (now + timedelta(seconds=10)).isoformat() - } - - -@asyncio.coroutine -def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): - """Test loading authentication with no stored auth.""" - mock_read.return_value = None - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_timeout_during_verification(cloud_hass, mock_read): - """Test loading authentication with timeout during verification.""" - mock_read.return_value = MOCK_AUTH - - with patch.object(cloud_api.Cloud, 'async_refresh_account_info', - side_effect=asyncio.TimeoutError): - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_verification_failed_500(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with verify request getting 500.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=500) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_token_refresh_needed_401(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with refresh needed which gets 401.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=403) - aioclient_mock.post(url('o/token/'), status=401) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_token_refresh_needed_500(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with refresh needed which gets 500.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=403) - aioclient_mock.post(url('o/token/'), status=500) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_token_refresh_needed_timeout(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with refresh timing out.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=403) - aioclient_mock.post(url('o/token/'), exc=asyncio.TimeoutError) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_token_refresh_needed_succeeds(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with refresh timing out.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=403) - - with patch.object(cloud_api.Cloud, 'async_refresh_access_token', - return_value=mock_coro(True)) as mock_refresh: - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - assert len(mock_refresh.mock_calls) == 1 - - -@asyncio.coroutine -def test_load_auth_token(cloud_hass, mock_read, aioclient_mock): - """Test loading authentication with refresh timing out.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), json={ - 'first_name': 'Paulus', - 'last_name': 'Schoutsen' - }) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is not None - assert result.account == { - 'first_name': 'Paulus', - 'last_name': 'Schoutsen' - } - assert result.auth == MOCK_AUTH - - -def test_cloud_properties(): - """Test Cloud class properties.""" - cloud = cloud_api.Cloud(None, MOCK_AUTH) - assert cloud.access_token == MOCK_AUTH['access_token'] - assert cloud.refresh_token == MOCK_AUTH['refresh_token'] - - -@asyncio.coroutine -def test_cloud_refresh_account_info(cloud_hass, aioclient_mock): - """Test refreshing account info.""" - aioclient_mock.get(url('account.json'), json={ - 'first_name': 'Paulus', - 'last_name': 'Schoutsen' - }) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - assert cloud.account is None - result = yield from cloud.async_refresh_account_info() - assert result - assert cloud.account == { - 'first_name': 'Paulus', - 'last_name': 'Schoutsen' - } - - -@asyncio.coroutine -def test_cloud_refresh_account_info_500(cloud_hass, aioclient_mock): - """Test refreshing account info and getting 500.""" - aioclient_mock.get(url('account.json'), status=500) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - assert cloud.account is None - result = yield from cloud.async_refresh_account_info() - assert not result - assert cloud.account is None - - -@asyncio.coroutine -def test_cloud_refresh_token(cloud_hass, aioclient_mock, mock_write): - """Test refreshing access token.""" - aioclient_mock.post(url('o/token/'), json={ - 'access_token': 'refreshed', - 'expires_in': 10 - }) - now = dt_util.utcnow() - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with patch('homeassistant.components.cloud.cloud_api.utcnow', - return_value=now): - result = yield from cloud.async_refresh_access_token() - assert result - assert cloud.auth == { - 'access_token': 'refreshed', - 'expires_in': 10, - 'expires_at': (now + timedelta(seconds=10)).isoformat() - } - assert len(mock_write.mock_calls) == 1 - write_hass, write_data = mock_write.mock_calls[0][1] - assert write_hass is cloud_hass - assert write_data == cloud.auth - - -@asyncio.coroutine -def test_cloud_refresh_token_unknown_error(cloud_hass, aioclient_mock, - mock_write): - """Test refreshing access token.""" - aioclient_mock.post(url('o/token/'), status=500) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - result = yield from cloud.async_refresh_access_token() - assert not result - assert cloud.auth == MOCK_AUTH - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_cloud_revoke_token(cloud_hass, aioclient_mock, mock_write): - """Test revoking access token.""" - aioclient_mock.post(url('o/revoke_token/')) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - yield from cloud.async_revoke_access_token() - assert cloud.auth is None - assert len(mock_write.mock_calls) == 1 - write_hass, write_data = mock_write.mock_calls[0][1] - assert write_hass is cloud_hass - assert write_data is None - - -@asyncio.coroutine -def test_cloud_revoke_token_invalid_client_creds(cloud_hass, aioclient_mock, - mock_write): - """Test revoking access token with invalid client credentials.""" - aioclient_mock.post(url('o/revoke_token/'), status=401) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with pytest.raises(cloud_api.UnknownError): - yield from cloud.async_revoke_access_token() - assert cloud.auth is not None - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_cloud_revoke_token_request_error(cloud_hass, aioclient_mock, - mock_write): - """Test revoking access token with invalid client credentials.""" - aioclient_mock.post(url('o/revoke_token/'), exc=aiohttp.ClientError) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with pytest.raises(cloud_api.UnknownError): - yield from cloud.async_revoke_access_token() - assert cloud.auth is not None - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_cloud_request(cloud_hass, aioclient_mock): - """Test making request to the cloud.""" - aioclient_mock.post(url('some_endpoint'), json={'hello': 'world'}) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - request = yield from cloud.async_request('post', 'some_endpoint') - assert request.status == 200 - data = yield from request.json() - assert data == {'hello': 'world'} - - -@asyncio.coroutine -def test_cloud_request_requiring_refresh_fail(cloud_hass, aioclient_mock): - """Test making request to the cloud.""" - aioclient_mock.post(url('some_endpoint'), status=403) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with patch.object(cloud_api.Cloud, 'async_refresh_access_token', - return_value=mock_coro(False)) as mock_refresh: - request = yield from cloud.async_request('post', 'some_endpoint') - assert request.status == 403 - assert len(mock_refresh.mock_calls) == 1 - - -@asyncio.coroutine -def test_cloud_request_requiring_refresh_success(cloud_hass, aioclient_mock): - """Test making request to the cloud.""" - aioclient_mock.post(url('some_endpoint'), status=403) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with patch.object(cloud_api.Cloud, 'async_refresh_access_token', - return_value=mock_coro(True)) as mock_refresh, \ - patch.object(cloud_api.Cloud, 'async_refresh_account_info', - return_value=mock_coro()) as mock_account_info: - request = yield from cloud.async_request('post', 'some_endpoint') - assert request.status == 403 - assert len(mock_refresh.mock_calls) == 1 - assert len(mock_account_info.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 99e73461bc1..fc9b3cce864 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -5,9 +5,7 @@ from unittest.mock import patch, MagicMock import pytest from homeassistant.bootstrap import async_setup_component -from homeassistant.components.cloud import DOMAIN, cloud_api - -from tests.common import mock_coro +from homeassistant.components.cloud import DOMAIN, auth_api @pytest.fixture @@ -21,6 +19,20 @@ def cloud_client(hass, test_client): return hass.loop.run_until_complete(test_client(hass.http.app)) +@pytest.fixture +def mock_auth(cloud_client, hass): + """Fixture to mock authentication.""" + auth = hass.data[DOMAIN]['auth'] = MagicMock() + return auth + + +@pytest.fixture +def mock_cognito(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: + yield mock_cog() + + @asyncio.coroutine def test_account_view_no_account(cloud_client): """Test fetching account if no account available.""" @@ -29,129 +41,300 @@ def test_account_view_no_account(cloud_client): @asyncio.coroutine -def test_account_view(hass, cloud_client): +def test_account_view(mock_auth, cloud_client): """Test fetching account if no account available.""" - cloud = MagicMock(account={'test': 'account'}) - hass.data[DOMAIN]['cloud'] = cloud + mock_auth.account = MagicMock(email='hello@home-assistant.io') req = yield from cloud_client.get('/api/cloud/account') assert req.status == 200 result = yield from req.json() - assert result == {'test': 'account'} + assert result == {'email': 'hello@home-assistant.io'} @asyncio.coroutine -def test_login_view(hass, cloud_client): +def test_login_view(mock_auth, cloud_client): """Test logging in.""" - cloud = MagicMock(account={'test': 'account'}) - cloud.async_refresh_account_info.return_value = mock_coro(None) - - with patch.object(cloud_api, 'async_login', - MagicMock(return_value=mock_coro(cloud))): - req = yield from cloud_client.post('/api/cloud/login', json={ - 'username': 'my_username', - 'password': 'my_password' - }) + mock_auth.account = MagicMock(email='hello@home-assistant.io') + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 200 - result = yield from req.json() - assert result == {'test': 'account'} - assert hass.data[DOMAIN]['cloud'] is cloud + assert result == {'email': 'hello@home-assistant.io'} + assert len(mock_auth.login.mock_calls) == 1 + result_user, result_pass = mock_auth.login.mock_calls[0][1] + assert result_user == 'my_username' + assert result_pass == 'my_password' @asyncio.coroutine -def test_login_view_invalid_json(hass, cloud_client): +def test_login_view_invalid_json(mock_auth, cloud_client): """Try logging in with invalid JSON.""" req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') assert req.status == 400 - assert 'cloud' not in hass.data[DOMAIN] + assert len(mock_auth.mock_calls) == 0 @asyncio.coroutine -def test_login_view_invalid_schema(hass, cloud_client): +def test_login_view_invalid_schema(mock_auth, cloud_client): """Try logging in with invalid schema.""" req = yield from cloud_client.post('/api/cloud/login', json={ 'invalid': 'schema' }) assert req.status == 400 - assert 'cloud' not in hass.data[DOMAIN] + assert len(mock_auth.mock_calls) == 0 @asyncio.coroutine -def test_login_view_request_timeout(hass, cloud_client): +def test_login_view_request_timeout(mock_auth, cloud_client): """Test request timeout while trying to log in.""" - with patch.object(cloud_api, 'async_login', - MagicMock(side_effect=asyncio.TimeoutError)): - req = yield from cloud_client.post('/api/cloud/login', json={ - 'username': 'my_username', - 'password': 'my_password' - }) + mock_auth.login.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 502 - assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine -def test_login_view_invalid_credentials(hass, cloud_client): +def test_login_view_invalid_credentials(mock_auth, cloud_client): """Test logging in with invalid credentials.""" - with patch.object(cloud_api, 'async_login', - MagicMock(side_effect=cloud_api.Unauthenticated)): - req = yield from cloud_client.post('/api/cloud/login', json={ - 'username': 'my_username', - 'password': 'my_password' - }) + mock_auth.login.side_effect = auth_api.Unauthenticated + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 401 - assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine -def test_login_view_unknown_error(hass, cloud_client): +def test_login_view_unknown_error(mock_auth, cloud_client): """Test unknown error while logging in.""" - with patch.object(cloud_api, 'async_login', - MagicMock(side_effect=cloud_api.UnknownError)): - req = yield from cloud_client.post('/api/cloud/login', json={ - 'username': 'my_username', - 'password': 'my_password' - }) + mock_auth.login.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) - assert req.status == 500 - assert 'cloud' not in hass.data[DOMAIN] + assert req.status == 502 @asyncio.coroutine -def test_logout_view(hass, cloud_client): +def test_logout_view(mock_auth, cloud_client): """Test logging out.""" - cloud = MagicMock() - cloud.async_revoke_access_token.return_value = mock_coro(None) - hass.data[DOMAIN]['cloud'] = cloud - req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 200 data = yield from req.json() - assert data == {'result': 'ok'} - assert 'cloud' not in hass.data[DOMAIN] + assert data == {'message': 'ok'} + assert len(mock_auth.logout.mock_calls) == 1 @asyncio.coroutine -def test_logout_view_request_timeout(hass, cloud_client): +def test_logout_view_request_timeout(mock_auth, cloud_client): """Test timeout while logging out.""" - cloud = MagicMock() - cloud.async_revoke_access_token.side_effect = asyncio.TimeoutError - hass.data[DOMAIN]['cloud'] = cloud - + mock_auth.logout.side_effect = asyncio.TimeoutError req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 502 - assert 'cloud' in hass.data[DOMAIN] @asyncio.coroutine -def test_logout_view_unknown_error(hass, cloud_client): +def test_logout_view_unknown_error(mock_auth, cloud_client): """Test unknown error while loggin out.""" - cloud = MagicMock() - cloud.async_revoke_access_token.side_effect = cloud_api.UnknownError - hass.data[DOMAIN]['cloud'] = cloud - + mock_auth.logout.side_effect = auth_api.UnknownError req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 502 - assert 'cloud' in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_register_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 200 + assert len(mock_cognito.register.mock_calls) == 1 + result_email, result_pass = mock_cognito.register.mock_calls[0][1] + assert result_email == 'hello@bla.com' + assert result_pass == 'falcon42' + + +@asyncio.coroutine +def test_register_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'not_password': 'falcon' + }) + assert req.status == 400 + assert len(mock_cognito.logout.mock_calls) == 0 + + +@asyncio.coroutine +def test_register_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.register.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_register_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while loggin out.""" + mock_cognito.register.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_register_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 200 + assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 + result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] + assert result_email == 'hello@bla.com' + assert result_code == '123456' + + +@asyncio.coroutine +def test_confirm_register_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'not_confirmation_code': '123456' + }) + assert req.status == 400 + assert len(mock_cognito.confirm_sign_up.mock_calls) == 0 + + +@asyncio.coroutine +def test_confirm_register_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.confirm_sign_up.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_register_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while loggin out.""" + mock_cognito.confirm_sign_up.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_forgot_password_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 200 + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + + +@asyncio.coroutine +def test_forgot_password_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'not_email': 'hello@bla.com', + }) + assert req.status == 400 + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 + + +@asyncio.coroutine +def test_forgot_password_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while loggin out.""" + mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_forgot_password_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 200 + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 + result_code, result_new_password = \ + mock_cognito.confirm_forgot_password.mock_calls[0][1] + assert result_code == '123456' + assert result_new_password == 'hello2' + + +@asyncio.coroutine +def test_confirm_forgot_password_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'not_confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 400 + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 0 + + +@asyncio.coroutine +def test_confirm_forgot_password_view_request_timeout(mock_cognito, + cloud_client): + """Test timeout while logging out.""" + mock_cognito.confirm_forgot_password.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_forgot_password_view_unknown_error(mock_cognito, + cloud_client): + """Test unknown error while loggin out.""" + mock_cognito.confirm_forgot_password.side_effect = auth_api.UnknownError + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 502 From 29b62f814faed976744121531e0dca214331020e Mon Sep 17 00:00:00 2001 From: Jeff McGehee Date: Tue, 12 Sep 2017 12:52:09 -0400 Subject: [PATCH 166/277] Allow multiple observations of same entity (#9391) * Allow multiple observations of same entity Why: * There may be different probabilities for multiple states of the same entity. This change addresses the need by: * Keeping a list of observations for each entity to check on each state change of the given entity. * Adding a numeric id to each observation so that they can be effectively added and removed from `self.current_obs`. * Adding a test to confirm functionality. * fix overzealous indenting --- .../components/binary_sensor/bayesian.py | 23 ++++--- .../components/binary_sensor/test_bayesian.py | 68 ++++++++++++++++++- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index ac328fd1f41..13908fb5472 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -102,7 +102,13 @@ class BayesianBinarySensor(BinarySensorDevice): self.current_obs = OrderedDict({}) - self.entity_obs = {obs['entity_id']: obs for obs in self._observations} + to_observe = set(obs['entity_id'] for obs in self._observations) + + self.entity_obs = dict.fromkeys(to_observe, []) + + for ind, obs in enumerate(self._observations): + obs["id"] = ind + self.entity_obs[obs['entity_id']].append(obs) self.watchers = { 'numeric_state': self._process_numeric_state, @@ -120,16 +126,17 @@ class BayesianBinarySensor(BinarySensorDevice): if new_state.state == STATE_UNKNOWN: return - entity_obs = self.entity_obs[entity] - platform = entity_obs['platform'] + entity_obs_list = self.entity_obs[entity] - self.watchers[platform](entity_obs) + for entity_obs in entity_obs_list: + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) prior = self.prior for obs in self.current_obs.values(): prior = update_probability(prior, obs['prob_true'], obs['prob_false']) - self.probability = prior self.hass.async_add_job(self.async_update_ha_state, True) @@ -140,20 +147,20 @@ class BayesianBinarySensor(BinarySensorDevice): def _update_current_obs(self, entity_observation, should_trigger): """Update current observation.""" - entity = entity_observation['entity_id'] + obs_id = entity_observation['id'] if should_trigger: prob_true = entity_observation['prob_given_true'] prob_false = entity_observation.get( 'prob_given_false', 1 - prob_true) - self.current_obs[entity] = { + self.current_obs[obs_id] = { 'prob_true': prob_true, 'prob_false': prob_false } else: - self.current_obs.pop(entity, None) + self.current_obs.pop(obs_id, None) def _process_numeric_state(self, entity_observation): """Add entity to current_obs if numeric state conditions are met.""" diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py index 61b110f247f..3b403c3702f 100644 --- a/tests/components/binary_sensor/test_bayesian.py +++ b/tests/components/binary_sensor/test_bayesian.py @@ -73,8 +73,7 @@ class TestBayesianBinarySensor(unittest.TestCase): 'prob_false': 0.1, 'prob_true': 0.9 }], state.attributes.get('observations')) - self.assertAlmostEqual(0.77, - state.attributes.get('probability')) + self.assertAlmostEqual(0.77, state.attributes.get('probability')) assert state.state == 'on' @@ -155,6 +154,71 @@ class TestBayesianBinarySensor(unittest.TestCase): assert state.state == 'off' + def test_multiple_observations(self): + """Test sensor with multiple observations of same entity.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'blue', + 'prob_given_true': 0.8, + 'prob_given_false': 0.4 + }, { + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'red', + 'prob_given_true': 0.2, + 'prob_given_false': 0.4 + }], + 'prior': + 0.2, + 'probability_threshold': + 0.32, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'off') + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_true': 0.8, + 'prob_false': 0.4 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.33, state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'red') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(0.11, state.attributes.get('probability')) + + assert state.state == 'off' + def test_probability_updates(self): """Test probability update function.""" prob_true = [0.3, 0.6, 0.8] From 05192e678ed44a95b01915d4fed94029700ac2b9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Sep 2017 12:24:44 -0700 Subject: [PATCH 167/277] Break up Alexa per functionality (#9400) * Break up Alexa per functionality * Lint * Lint --- homeassistant/components/alexa/__init__.py | 52 ++++++++ homeassistant/components/alexa/const.py | 18 +++ .../components/alexa/flash_briefings.py | 96 +++++++++++++ .../components/{alexa.py => alexa/intent.py} | 126 +----------------- tests/components/alexa/__init__.py | 1 + .../components/alexa/test_flash_briefings.py | 98 ++++++++++++++ .../{test_alexa.py => alexa/test_intent.py} | 66 +-------- 7 files changed, 272 insertions(+), 185 deletions(-) create mode 100644 homeassistant/components/alexa/__init__.py create mode 100644 homeassistant/components/alexa/const.py create mode 100644 homeassistant/components/alexa/flash_briefings.py rename homeassistant/components/{alexa.py => alexa/intent.py} (60%) create mode 100644 tests/components/alexa/__init__.py create mode 100644 tests/components/alexa/test_flash_briefings.py rename tests/components/{test_alexa.py => alexa/test_intent.py} (87%) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py new file mode 100644 index 00000000000..65243aa83ce --- /dev/null +++ b/homeassistant/components/alexa/__init__.py @@ -0,0 +1,52 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from .const import ( + DOMAIN, CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL) +from . import flash_briefings, intent + +_LOGGER = logging.getLogger(__name__) + + +DEPENDENCIES = ['http'] + +CONF_FLASH_BRIEFINGS = 'flash_briefings' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_FLASH_BRIEFINGS: { + cv.string: vol.All(cv.ensure_list, [{ + vol.Optional(CONF_UID): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Optional(CONF_AUDIO): cv.template, + vol.Required(CONF_TEXT, default=""): cv.template, + vol.Optional(CONF_DISPLAY_URL): cv.template, + }]), + } + } +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Activate Alexa component.""" + config = config.get(DOMAIN, {}) + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) + + intent.async_setup(hass) + + if flash_briefings_config: + flash_briefings.async_setup(hass, flash_briefings_config) + + return True diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py new file mode 100644 index 00000000000..9550b6dbade --- /dev/null +++ b/homeassistant/components/alexa/const.py @@ -0,0 +1,18 @@ +"""Constants for the Alexa integration.""" +DOMAIN = 'alexa' + +# Flash briefing constants +CONF_UID = 'uid' +CONF_TITLE = 'title' +CONF_AUDIO = 'audio' +CONF_TEXT = 'text' +CONF_DISPLAY_URL = 'display_url' + +ATTR_UID = 'uid' +ATTR_UPDATE_DATE = 'updateDate' +ATTR_TITLE_TEXT = 'titleText' +ATTR_STREAM_URL = 'streamUrl' +ATTR_MAIN_TEXT = 'mainText' +ATTR_REDIRECTION_URL = 'redirectionURL' + +DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py new file mode 100644 index 00000000000..ec7e3521c0a --- /dev/null +++ b/homeassistant/components/alexa/flash_briefings.py @@ -0,0 +1,96 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import copy +import logging +from datetime import datetime +import uuid + +from homeassistant.core import callback +from homeassistant.helpers import template +from homeassistant.components import http + +from .const import ( + CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL, ATTR_UID, + ATTR_UPDATE_DATE, ATTR_TITLE_TEXT, ATTR_STREAM_URL, ATTR_MAIN_TEXT, + ATTR_REDIRECTION_URL, DATE_FORMAT) + + +_LOGGER = logging.getLogger(__name__) + +FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' + + +@callback +def async_setup(hass, flash_briefing_config): + """Activate Alexa component.""" + hass.http.register_view( + AlexaFlashBriefingView(hass, flash_briefing_config)) + + +class AlexaFlashBriefingView(http.HomeAssistantView): + """Handle Alexa Flash Briefing skill requests.""" + + url = FLASH_BRIEFINGS_API_ENDPOINT + name = 'api:alexa:flash_briefings' + + def __init__(self, hass, flash_briefings): + """Initialize Alexa view.""" + super().__init__() + self.flash_briefings = copy.deepcopy(flash_briefings) + template.attach(hass, self.flash_briefings) + + @callback + def get(self, request, briefing_id): + """Handle Alexa Flash Briefing request.""" + _LOGGER.debug('Received Alexa flash briefing request for: %s', + briefing_id) + + if self.flash_briefings.get(briefing_id) is None: + err = 'No configured Alexa flash briefing was found for: %s' + _LOGGER.error(err, briefing_id) + return b'', 404 + + briefing = [] + + for item in self.flash_briefings.get(briefing_id, []): + output = {} + if item.get(CONF_TITLE) is not None: + if isinstance(item.get(CONF_TITLE), template.Template): + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() + else: + output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) + + if item.get(CONF_TEXT) is not None: + if isinstance(item.get(CONF_TEXT), template.Template): + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() + else: + output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) + + uid = item.get(CONF_UID) + if uid is None: + uid = str(uuid.uuid4()) + output[ATTR_UID] = uid + + if item.get(CONF_AUDIO) is not None: + if isinstance(item.get(CONF_AUDIO), template.Template): + output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() + else: + output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) + + if item.get(CONF_DISPLAY_URL) is not None: + if isinstance(item.get(CONF_DISPLAY_URL), + template.Template): + output[ATTR_REDIRECTION_URL] = \ + item[CONF_DISPLAY_URL].async_render() + else: + output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) + + output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) + + briefing.append(output) + + return self.json(briefing) diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa/intent.py similarity index 60% rename from homeassistant/components/alexa.py rename to homeassistant/components/alexa/intent.py index 25b6537e255..a0d0062414d 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa/intent.py @@ -5,52 +5,19 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/alexa/ """ import asyncio -import copy import enum import logging -import uuid -from datetime import datetime - -import voluptuous as vol from homeassistant.core import callback from homeassistant.const import HTTP_BAD_REQUEST -from homeassistant.helpers import intent, template, config_validation as cv +from homeassistant.helpers import intent from homeassistant.components import http -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN INTENTS_API_ENDPOINT = '/api/alexa' -FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' -CONF_ACTION = 'action' -CONF_CARD = 'card' -CONF_INTENTS = 'intents' -CONF_SPEECH = 'speech' - -CONF_TYPE = 'type' -CONF_TITLE = 'title' -CONF_CONTENT = 'content' -CONF_TEXT = 'text' - -CONF_FLASH_BRIEFINGS = 'flash_briefings' -CONF_UID = 'uid' -CONF_TITLE = 'title' -CONF_AUDIO = 'audio' -CONF_TEXT = 'text' -CONF_DISPLAY_URL = 'display_url' - -ATTR_UID = 'uid' -ATTR_UPDATE_DATE = 'updateDate' -ATTR_TITLE_TEXT = 'titleText' -ATTR_STREAM_URL = 'streamUrl' -ATTR_MAIN_TEXT = 'mainText' -ATTR_REDIRECTION_URL = 'redirectionURL' - -DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' - -DOMAIN = 'alexa' -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) class SpeechType(enum.Enum): @@ -73,30 +40,10 @@ class CardType(enum.Enum): link_account = "LinkAccount" -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_FLASH_BRIEFINGS: { - cv.string: vol.All(cv.ensure_list, [{ - vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, - vol.Required(CONF_TITLE): cv.template, - vol.Optional(CONF_AUDIO): cv.template, - vol.Required(CONF_TEXT, default=""): cv.template, - vol.Optional(CONF_DISPLAY_URL): cv.template, - }]), - } - } -}, extra=vol.ALLOW_EXTRA) - - -@asyncio.coroutine -def async_setup(hass, config): +@callback +def async_setup(hass): """Activate Alexa component.""" - flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) - hass.http.register_view(AlexaIntentsView) - hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings)) - - return True class AlexaIntentsView(http.HomeAssistantView): @@ -255,66 +202,3 @@ class AlexaResponse(object): 'sessionAttributes': self.session_attributes, 'response': response, } - - -class AlexaFlashBriefingView(http.HomeAssistantView): - """Handle Alexa Flash Briefing skill requests.""" - - url = FLASH_BRIEFINGS_API_ENDPOINT - name = 'api:alexa:flash_briefings' - - def __init__(self, hass, flash_briefings): - """Initialize Alexa view.""" - super().__init__() - self.flash_briefings = copy.deepcopy(flash_briefings) - template.attach(hass, self.flash_briefings) - - @callback - def get(self, request, briefing_id): - """Handle Alexa Flash Briefing request.""" - _LOGGER.debug('Received Alexa flash briefing request for: %s', - briefing_id) - - if self.flash_briefings.get(briefing_id) is None: - err = 'No configured Alexa flash briefing was found for: %s' - _LOGGER.error(err, briefing_id) - return b'', 404 - - briefing = [] - - for item in self.flash_briefings.get(briefing_id, []): - output = {} - if item.get(CONF_TITLE) is not None: - if isinstance(item.get(CONF_TITLE), template.Template): - output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() - else: - output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) - - if item.get(CONF_TEXT) is not None: - if isinstance(item.get(CONF_TEXT), template.Template): - output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() - else: - output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) - - if item.get(CONF_UID) is not None: - output[ATTR_UID] = item.get(CONF_UID) - - if item.get(CONF_AUDIO) is not None: - if isinstance(item.get(CONF_AUDIO), template.Template): - output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() - else: - output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) - - if item.get(CONF_DISPLAY_URL) is not None: - if isinstance(item.get(CONF_DISPLAY_URL), - template.Template): - output[ATTR_REDIRECTION_URL] = \ - item[CONF_DISPLAY_URL].async_render() - else: - output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) - - output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) - - briefing.append(output) - - return self.json(briefing) diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py new file mode 100644 index 00000000000..88ecc63d200 --- /dev/null +++ b/tests/components/alexa/__init__.py @@ -0,0 +1 @@ +"""Tests for the Alexa integration.""" diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py new file mode 100644 index 00000000000..d9f0c8e156d --- /dev/null +++ b/tests/components/alexa/test_flash_briefings.py @@ -0,0 +1,98 @@ +"""The tests for the Alexa component.""" +# pylint: disable=protected-access +import asyncio +import datetime + +import pytest + +from homeassistant.core import callback +from homeassistant.setup import async_setup_component +from homeassistant.components import alexa +from homeassistant.components.alexa import const + +SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" +APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" +REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" + +# pylint: disable=invalid-name +calls = [] + +NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" + + +@pytest.fixture +def alexa_client(loop, hass, test_client): + """Initialize a Home Assistant server for testing this module.""" + @callback + def mock_service(call): + calls.append(call) + + hass.services.async_register("test", "alexa", mock_service) + + assert loop.run_until_complete(async_setup_component(hass, alexa.DOMAIN, { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "weather": [ + {"title": "Weekly forecast", + "text": "This week it will be sunny."}, + {"title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit."} + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "uid": "uuid" + } + }, + } + })) + return loop.run_until_complete(test_client(hass.http.app)) + + +def _flash_briefing_req(client, briefing_id): + return client.get( + "/api/alexa/flash_briefings/{}".format(briefing_id)) + + +@asyncio.coroutine +def test_flash_briefing_invalid_id(alexa_client): + """Test an invalid Flash Briefing ID.""" + req = yield from _flash_briefing_req(alexa_client, 10000) + assert req.status == 404 + text = yield from req.text() + assert text == '' + + +@asyncio.coroutine +def test_flash_briefing_date_from_str(alexa_client): + """Test the response has a valid date parsed from string.""" + req = yield from _flash_briefing_req(alexa_client, "weather") + assert req.status == 200 + data = yield from req.json() + assert isinstance(datetime.datetime.strptime(data[0].get( + const.ATTR_UPDATE_DATE), const.DATE_FORMAT), datetime.datetime) + + +@asyncio.coroutine +def test_flash_briefing_valid(alexa_client): + """Test the response is valid.""" + data = [{ + "titleText": "NPR", + "redirectionURL": "https://npr.org", + "streamUrl": NPR_NEWS_MP3_URL, + "mainText": "", + "uid": "uuid", + "updateDate": '2016-10-10T19:51:42.0Z' + }] + + req = yield from _flash_briefing_req(alexa_client, "news_audio") + assert req.status == 200 + json = yield from req.json() + assert isinstance(datetime.datetime.strptime(json[0].get( + const.ATTR_UPDATE_DATE), const.DATE_FORMAT), datetime.datetime) + json[0].pop(const.ATTR_UPDATE_DATE) + data[0].pop(const.ATTR_UPDATE_DATE) + assert json == data diff --git a/tests/components/test_alexa.py b/tests/components/alexa/test_intent.py similarity index 87% rename from tests/components/test_alexa.py rename to tests/components/alexa/test_intent.py index 3789e7ab615..565ebec64aa 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/alexa/test_intent.py @@ -2,13 +2,13 @@ # pylint: disable=protected-access import asyncio import json -import datetime import pytest from homeassistant.core import callback from homeassistant.setup import async_setup_component from homeassistant.components import alexa +from homeassistant.components.alexa import intent SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" @@ -32,22 +32,6 @@ def alexa_client(loop, hass, test_client): assert loop.run_until_complete(async_setup_component(hass, alexa.DOMAIN, { # Key is here to verify we allow other keys in config too "homeassistant": {}, - "alexa": { - "flash_briefings": { - "weather": [ - {"title": "Weekly forecast", - "text": "This week it will be sunny."}, - {"title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit."} - ], - "news_audio": { - "title": "NPR", - "audio": NPR_NEWS_MP3_URL, - "display_url": "https://npr.org", - "uid": "uuid" - } - }, - } })) assert loop.run_until_complete(async_setup_component( hass, 'intent_script', { @@ -113,15 +97,10 @@ def alexa_client(loop, hass, test_client): def _intent_req(client, data={}): - return client.post(alexa.INTENTS_API_ENDPOINT, data=json.dumps(data), + return client.post(intent.INTENTS_API_ENDPOINT, data=json.dumps(data), headers={'content-type': 'application/json'}) -def _flash_briefing_req(client, briefing_id): - return client.get( - "/api/alexa/flash_briefings/{}".format(briefing_id)) - - @asyncio.coroutine def test_intent_launch_request(alexa_client): """Test the launch of a request.""" @@ -467,44 +446,3 @@ def test_intent_from_built_in_intent_library(alexa_client): text = data.get("response", {}).get("outputSpeech", {}).get("text") assert text == "Playing the shins." - - -@asyncio.coroutine -def test_flash_briefing_invalid_id(alexa_client): - """Test an invalid Flash Briefing ID.""" - req = yield from _flash_briefing_req(alexa_client, 10000) - assert req.status == 404 - text = yield from req.text() - assert text == '' - - -@asyncio.coroutine -def test_flash_briefing_date_from_str(alexa_client): - """Test the response has a valid date parsed from string.""" - req = yield from _flash_briefing_req(alexa_client, "weather") - assert req.status == 200 - data = yield from req.json() - assert isinstance(datetime.datetime.strptime(data[0].get( - alexa.ATTR_UPDATE_DATE), alexa.DATE_FORMAT), datetime.datetime) - - -@asyncio.coroutine -def test_flash_briefing_valid(alexa_client): - """Test the response is valid.""" - data = [{ - "titleText": "NPR", - "redirectionURL": "https://npr.org", - "streamUrl": NPR_NEWS_MP3_URL, - "mainText": "", - "uid": "uuid", - "updateDate": '2016-10-10T19:51:42.0Z' - }] - - req = yield from _flash_briefing_req(alexa_client, "news_audio") - assert req.status == 200 - json = yield from req.json() - assert isinstance(datetime.datetime.strptime(json[0].get( - alexa.ATTR_UPDATE_DATE), alexa.DATE_FORMAT), datetime.datetime) - json[0].pop(alexa.ATTR_UPDATE_DATE) - data[0].pop(alexa.ATTR_UPDATE_DATE) - assert json == data From fdf2d24a8b5f90081e25402943370b2c65820e9b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 13 Sep 2017 00:54:25 +0200 Subject: [PATCH 168/277] Upgrade psutil to 5.3.1 (#9403) --- homeassistant/components/sensor/systemmonitor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 69a82fb0fac..1a8d67de93e 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.3.0'] +REQUIREMENTS = ['psutil==5.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a8b92b45d59..7a8ee1f6609 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,7 +510,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.3.0 +psutil==5.3.1 # homeassistant.components.wink pubnubsub-handler==1.0.2 From c8da95c1e87418753e425364510cb462e7c70d27 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Tue, 12 Sep 2017 22:50:28 -0400 Subject: [PATCH 169/277] fix mopar sensor (#9389) * fix mopar sensor * fix typo * bump mopar dep version --- homeassistant/components/sensor/mopar.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py index 0184cb2afdf..66eea20ec70 100644 --- a/homeassistant/components/sensor/mopar.py +++ b/homeassistant/components/sensor/mopar.py @@ -18,7 +18,7 @@ from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['motorparts==1.0.0'] +REQUIREMENTS = ['motorparts==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -86,6 +86,7 @@ class MoparData(object): self.vehicles = [] self.vhrs = {} self.tow_guides = {} + self.update() @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self, **kwargs): diff --git a/requirements_all.txt b/requirements_all.txt index 7a8ee1f6609..674f6b68351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -416,7 +416,7 @@ miflora==0.1.16 miniupnpc==1.9 # homeassistant.components.sensor.mopar -motorparts==1.0.0 +motorparts==1.0.2 # homeassistant.components.tts mutagen==1.38 From f5ffef3f7259028122097bed1f763924a8794dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Wed, 13 Sep 2017 04:57:31 +0200 Subject: [PATCH 170/277] Support specifying no Apple TVs (#9394) --- homeassistant/components/apple_tv.py | 36 ++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 7a2ff7610f7..4fce508ba7e 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -10,6 +10,7 @@ import logging import voluptuous as vol +from typing import Union, TypeVar, Sequence from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.config import load_yaml_config_file from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -45,8 +46,19 @@ NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication' NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification' NOTIFICATION_SCAN_TITLE = 'Apple TV Scan' +T = TypeVar('T') + + +# This version of ensure_list interprets an empty dict as no value +def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]: + """Wrap value in list if it is not one.""" + if value is None or (isinstance(value, dict) and not value): + return [] + return value if isinstance(value, list) else [value] + + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + DOMAIN: vol.All(ensure_list, [vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_LOGIN_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -133,6 +145,10 @@ def async_setup(hass, config): """Handler for service calls.""" entity_ids = service.data.get(ATTR_ENTITY_ID) + if service.service == SERVICE_SCAN: + hass.async_add_job(scan_for_apple_tvs, hass) + return + if entity_ids: devices = [device for device in hass.data[DATA_ENTITIES] if device.entity_id in entity_ids] @@ -140,16 +156,16 @@ def async_setup(hass, config): devices = hass.data[DATA_ENTITIES] for device in devices: + if service.service != SERVICE_AUTHENTICATE: + continue + atv = device.atv - if service.service == SERVICE_AUTHENTICATE: - credentials = yield from atv.airplay.generate_credentials() - yield from atv.airplay.load_credentials(credentials) - _LOGGER.debug('Generated new credentials: %s', credentials) - yield from atv.airplay.start_authentication() - hass.async_add_job(request_configuration, - hass, config, atv, credentials) - elif service.service == SERVICE_SCAN: - hass.async_add_job(scan_for_apple_tvs, hass) + credentials = yield from atv.airplay.generate_credentials() + yield from atv.airplay.load_credentials(credentials) + _LOGGER.debug('Generated new credentials: %s', credentials) + yield from atv.airplay.start_authentication() + hass.async_add_job(request_configuration, + hass, config, atv, credentials) @asyncio.coroutine def atv_discovered(service, info): From 2c8967d0d5987b09302144755c4103fe00f94faf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Sep 2017 20:43:35 -0700 Subject: [PATCH 171/277] Update netdisco to 1.2.0 (#9408) --- 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 c757d9d1ce3..232230a2241 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.1.0'] +REQUIREMENTS = ['netdisco==1.2.0'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index 674f6b68351..c38ee410115 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -432,7 +432,7 @@ myusps==1.1.3 nad_receiver==0.0.6 # homeassistant.components.discovery -netdisco==1.1.0 +netdisco==1.2.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From d90801f6dd732c074a95f3268954b07af86d49e6 Mon Sep 17 00:00:00 2001 From: Paul Sokolovsky Date: Wed, 13 Sep 2017 06:44:42 +0300 Subject: [PATCH 172/277] components/xiaomi: Add initial discovery using NetDisco. (#9283) There's a kind of duplication of functionality between NetDisco and "xiaomi" component, the latter features its own "discovery" in addition to general HomeAssistant discovery service, based on NetDisco. As such, this patch is pretty simple: the only purpose of NetDisco discovery is "plug and play", "zero configuration" discovery that Xiaomi Gateway device is present on the local network, and triggering of "xiaomi" component loading, which then "rediscovers" the gateway using its own method. --- homeassistant/components/discovery.py | 2 ++ homeassistant/components/xiaomi.py | 21 +++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 232230a2241..1f8b12eef6b 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -34,6 +34,7 @@ SERVICE_HASSIO = 'hassio' SERVICE_AXIS = 'axis' SERVICE_APPLE_TV = 'apple_tv' SERVICE_WINK = 'wink' +SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -44,6 +45,7 @@ SERVICE_HANDLERS = { SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), + SERVICE_XIAOMI_GW: ('xiaomi', None), 'philips_hue': ('light', 'hue'), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi.py index 1d14a76d251..ac197d2d942 100644 --- a/homeassistant/components/xiaomi.py +++ b/homeassistant/components/xiaomi.py @@ -4,10 +4,10 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity +from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC) - REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' '0.3.2.zip#PyXiaomiGateway==0.3.2'] @@ -57,9 +57,22 @@ _LOGGER = logging.getLogger(__name__) def setup(hass, config): """Set up the Xiaomi component.""" - gateways = config[DOMAIN][CONF_GATEWAYS] - interface = config[DOMAIN][CONF_INTERFACE] - discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + gateways = [] + interface = 'any' + discovery_retry = 3 + if DOMAIN in config: + gateways = config[DOMAIN][CONF_GATEWAYS] + interface = config[DOMAIN][CONF_INTERFACE] + discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + + def xiaomi_gw_discovered(service, discovery_info): + """Called when Xiaomi Gateway device(s) has been found.""" + # We don't need to do anything here, the purpose of HA's + # discovery service is to just trigger loading of this + # component, and then its own discovery process kicks in. + _LOGGER.info("Discovered: %s", discovery_info) + + discovery.listen(hass, SERVICE_XIAOMI_GW, xiaomi_gw_discovered) from PyXiaomiGateway import PyXiaomiGateway hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway(hass.add_job, gateways, From 89d6784fa0ca550d079e7cb56359028585edbd62 Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Wed, 13 Sep 2017 17:00:46 +0200 Subject: [PATCH 173/277] Fix copy&paste mistake (#9378) --- tests/components/switch/test_mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index cc97fe1c9c3..21ab1dd31f2 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -9,7 +9,7 @@ from tests.common import ( mock_mqtt_component, fire_mqtt_message, get_test_home_assistant) -class TestSensorMQTT(unittest.TestCase): +class TestSwitchMQTT(unittest.TestCase): """Test the MQTT switch.""" def setUp(self): # pylint: disable=invalid-name From 411c9620c15f9bcb22f387842ad9c16306481a66 Mon Sep 17 00:00:00 2001 From: Ted Drain Date: Wed, 13 Sep 2017 21:22:42 -0700 Subject: [PATCH 174/277] Added log-file command line flag (#9422) --- homeassistant/__main__.py | 11 +++++++++-- homeassistant/bootstrap.py | 31 ++++++++++++++++++++----------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 2ce574ca15e..a8852b910c2 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -126,6 +126,12 @@ def get_arguments() -> argparse.Namespace: type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--log-file', + type=str, + default=None, + help='Log file to write to. If not set, CONFIG/home-assistant.log ' + 'is used') parser.add_argument( '--runner', action='store_true', @@ -256,13 +262,14 @@ def setup_and_run_hass(config_dir: str, } hass = bootstrap.from_config_dict( config, config_dir=config_dir, verbose=args.verbose, - skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, + log_file=args.log_file) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( config_file, verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days) + log_rotate_days=args.log_rotate_days, log_file=args.log_file) if hass is None: return None diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7831036ff59..1fa113ab597 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -38,7 +38,8 @@ def from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -56,7 +57,7 @@ def from_config_dict(config: Dict[str, Any], hass = hass.loop.run_until_complete( async_from_config_dict( config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days) + log_rotate_days, log_file) ) return hass @@ -69,7 +70,8 @@ def async_from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -88,7 +90,7 @@ def async_from_config_dict(config: Dict[str, Any], yield from hass.async_add_job(conf_util.process_ha_config_upgrade, hass) if enable_log: - async_enable_logging(hass, verbose, log_rotate_days) + async_enable_logging(hass, verbose, log_rotate_days, log_file) hass.config.skip_pip = skip_pip if skip_pip: @@ -153,7 +155,8 @@ def from_config_file(config_path: str, hass: Optional[core.HomeAssistant]=None, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -165,7 +168,7 @@ def from_config_file(config_path: str, # run task hass = hass.loop.run_until_complete( async_from_config_file( - config_path, hass, verbose, skip_pip, log_rotate_days) + config_path, hass, verbose, skip_pip, log_rotate_days, log_file) ) return hass @@ -176,7 +179,8 @@ def async_from_config_file(config_path: str, hass: core.HomeAssistant, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -187,7 +191,7 @@ def async_from_config_file(config_path: str, hass.config.config_dir = config_dir yield from async_mount_local_lib_path(config_dir, hass.loop) - async_enable_logging(hass, verbose, log_rotate_days) + async_enable_logging(hass, verbose, log_rotate_days, log_file) try: config_dict = yield from hass.async_add_job( @@ -205,7 +209,7 @@ def async_from_config_file(config_path: str, @core.callback def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, - log_rotate_days=None) -> None: + log_rotate_days=None, log_file=None) -> None: """Set up the logging. This method must be run in the event loop. @@ -239,13 +243,18 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, pass # Log errors to a file if we have write access to file or config dir - err_log_path = hass.config.path(ERROR_LOG_FILENAME) + if log_file is None: + err_log_path = hass.config.path(ERROR_LOG_FILENAME) + else: + err_log_path = os.path.abspath(log_file) + err_path_exists = os.path.isfile(err_log_path) + err_dir = os.path.dirname(err_log_path) # Check if we can write to the error log if it exists or that # we can create files in the containing directory if not. if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ - (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)): + (not err_path_exists and os.access(err_dir, os.W_OK)): if log_rotate_days: err_handler = logging.handlers.TimedRotatingFileHandler( From b21bfe50d7a2048d7442a205e3176146990deebf Mon Sep 17 00:00:00 2001 From: morberg Date: Thu, 14 Sep 2017 06:35:25 +0200 Subject: [PATCH 175/277] Add LC_CTYPE to environment variables in macOS (#9227) * Add LANG to environment variables Some componentes, e.g. tradfri, will not work properly unless LANG is an UTF-8 environment. * Set LC_CTYPE to UTF-8 --- homeassistant/scripts/macos/launchd.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/scripts/macos/launchd.plist b/homeassistant/scripts/macos/launchd.plist index ba067387f55..920f45a0c0e 100644 --- a/homeassistant/scripts/macos/launchd.plist +++ b/homeassistant/scripts/macos/launchd.plist @@ -9,6 +9,8 @@ PATH /usr/local/bin/:/usr/bin:/usr/sbin:$PATH + LC_CTYPE + UTF-8 Program From 5b453ca53a9f3b32f11868377b4cf12c70f5d049 Mon Sep 17 00:00:00 2001 From: Tor Magnus Date: Thu, 14 Sep 2017 07:14:38 +0200 Subject: [PATCH 176/277] Added more devices and types to onewire (#9404) * Added more devices and sensor types. * flake8 fixes * Resolved feedback in pull https://github.com/home-assistant/home-assistant/pull/9404 * Fixed issue where values would get mixed up across restarts of HA --- homeassistant/components/sensor/onewire.py | 102 +++++++++++---------- 1 file changed, 53 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/sensor/onewire.py b/homeassistant/components/sensor/onewire.py index 5cbbe6ed0aa..b36e7bdf267 100644 --- a/homeassistant/components/sensor/onewire.py +++ b/homeassistant/components/sensor/onewire.py @@ -1,5 +1,5 @@ """ -Support for 1-Wire temperature sensors. +Support for 1-Wire environment sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.onewire/ @@ -22,7 +22,22 @@ CONF_MOUNT_DIR = 'mount_dir' CONF_NAMES = 'names' DEFAULT_MOUNT_DIR = '/sys/bus/w1/devices/' -DEVICE_FAMILIES = ('10', '22', '28', '3B', '42') +DEVICE_SENSORS = {'10': {'temperature': 'temperature'}, + '12': {'temperature': 'TAI8570/temperature', + 'pressure': 'TAI8570/pressure'}, + '22': {'temperature': 'temperature'}, + '26': {'temperature': 'temperature', + 'humidity': 'humidity', + 'pressure': 'B1-R1-A/pressure'}, + '28': {'temperature': 'temperature'}, + '3B': {'temperature': 'temperature'}, + '42': {'temperature': 'temperature'}} + +SENSOR_TYPES = { + 'temperature': ['temperature', TEMP_CELSIUS], + 'humidity': ['humidity', '%'], + 'pressure': ['pressure', 'mb'], +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAMES): {cv.string: cv.string}, @@ -34,63 +49,54 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the one wire Sensors.""" base_dir = config.get(CONF_MOUNT_DIR) - sensor_ids = [] - device_files = [] + devs = [] + device_names = {} + if 'names' in config: + if isinstance(config['names'], dict): + device_names = config['names'] + if base_dir == DEFAULT_MOUNT_DIR: - for device_family in DEVICE_FAMILIES: + for device_family in DEVICE_SENSORS: for device_folder in glob(os.path.join(base_dir, device_family + '[.-]*')): - sensor_ids.append(os.path.split(device_folder)[1]) - device_files.append(os.path.join(device_folder, 'w1_slave')) + sensor_id = os.path.split(device_folder)[1] + device_file = os.path.join(device_folder, 'w1_slave') + devs.append(OneWire(device_names.get(sensor_id, sensor_id), + device_file, 'temperature')) else: for family_file_path in glob(os.path.join(base_dir, '*', 'family')): family_file = open(family_file_path, "r") family = family_file.read() - if family in DEVICE_FAMILIES: - sensor_id = os.path.split( - os.path.split(family_file_path)[0])[1] - sensor_ids.append(sensor_id) - device_files.append(os.path.join( - os.path.split(family_file_path)[0], 'temperature')) + if family in DEVICE_SENSORS: + for sensor_key, sensor_value in DEVICE_SENSORS[family].items(): + sensor_id = os.path.split( + os.path.split(family_file_path)[0])[1] + device_file = os.path.join( + os.path.split(family_file_path)[0], sensor_value) + devs.append(OneWire(device_names.get(sensor_id, sensor_id), + device_file, sensor_key)) - if device_files == []: + if devs == []: _LOGGER.error("No onewire sensor found. Check if dtoverlay=w1-gpio " "is in your /boot/config.txt. " "Check the mount_dir parameter if it's defined") return - devs = [] - names = sensor_ids - - for key in config.keys(): - if key == 'names': - # Only one name given - if isinstance(config['names'], str): - names = [config['names']] - # Map names and sensors in given order - elif isinstance(config['names'], list): - names = config['names'] - # Map names to ids. - elif isinstance(config['names'], dict): - names = [] - for sensor_id in sensor_ids: - names.append(config['names'].get(sensor_id, sensor_id)) - for device_file, name in zip(device_files, names): - devs.append(OneWire(name, device_file)) add_devices(devs, True) class OneWire(Entity): """Implementation of an One wire Sensor.""" - def __init__(self, name, device_file): + def __init__(self, name, device_file, sensor_type): """Initialize the sensor.""" - self._name = name + self._name = name+' '+sensor_type.capitalize() self._device_file = device_file + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._state = None - def _read_temp_raw(self): - """Read the temperature as it is returned by the sensor.""" + def _read_value_raw(self): + """Read the value as it is returned by the sensor.""" ds_device_file = open(self._device_file, 'r') lines = ds_device_file.readlines() ds_device_file.close() @@ -109,34 +115,32 @@ class OneWire(Entity): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return TEMP_CELSIUS + return self._unit_of_measurement def update(self): """Get the latest data from the device.""" - temp = -99 + value = None if self._device_file.startswith(DEFAULT_MOUNT_DIR): - lines = self._read_temp_raw() + lines = self._read_value_raw() while lines[0].strip()[-3:] != 'YES': time.sleep(0.2) - lines = self._read_temp_raw() + lines = self._read_value_raw() equals_pos = lines[1].find('t=') if equals_pos != -1: - temp_string = lines[1][equals_pos+2:] - temp = round(float(temp_string) / 1000.0, 1) + value_string = lines[1][equals_pos+2:] + value = round(float(value_string) / 1000.0, 1) else: try: ds_device_file = open(self._device_file, 'r') - temp_read = ds_device_file.readlines() + value_read = ds_device_file.readlines() ds_device_file.close() - if len(temp_read) == 1: - temp = round(float(temp_read[0]), 1) + if len(value_read) == 1: + value = round(float(value_read[0]), 1) except ValueError: - _LOGGER.warning("Invalid temperature value read from %s", + _LOGGER.warning("Invalid value read from %s", self._device_file) except FileNotFoundError: _LOGGER.warning( "Cannot read from sensor: %s", self._device_file) - if temp < -55 or temp > 125: - return - self._state = temp + self._state = value From 07cb7b3d547846f7e80e7d498508411fe172de8c Mon Sep 17 00:00:00 2001 From: Antony Messerli Date: Thu, 14 Sep 2017 00:21:58 -0500 Subject: [PATCH 177/277] Bump uvcclient to 0.10.1 to work with beta NVR releases (#9423) --- homeassistant/components/camera/uvc.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 3203a10b391..685b6d64364 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_PORT from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['uvcclient==0.10.0'] +REQUIREMENTS = ['uvcclient==0.10.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c38ee410115..4473afdf37a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -978,7 +978,7 @@ uber_rides==0.5.2 upsmychoice==1.0.6 # homeassistant.components.camera.uvc -uvcclient==0.10.0 +uvcclient==0.10.1 # homeassistant.components.volvooncall volvooncall==0.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea09ebbc648..4e543a8eada 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -139,7 +139,7 @@ sqlalchemy==1.1.13 statsd==3.2.1 # homeassistant.components.camera.uvc -uvcclient==0.10.0 +uvcclient==0.10.1 # homeassistant.components.cloud warrant==0.2.0 From 28d312803b4c84308b6b9cf9b489f37bad9cad81 Mon Sep 17 00:00:00 2001 From: spektren <31916694+spektren@users.noreply.github.com> Date: Thu, 14 Sep 2017 07:24:46 +0200 Subject: [PATCH 178/277] full RGB support for users of tradfri GW (#9411) * Update tradfri.py ## 201709013: set_hex_color() seems not to work in pytradfri api - set_rgb_color() does ## -> changed function set_hex_color() to set_rgb_color() ## tested w. IKEA tradfri GW and zigbee rgb PWM module (dresden elektronik FLS-PP lp) * Update tradfri.py Setup: Home Assistant 0.53.0 pytradfri 2.2 IKEA tradfri gateway fw 1.1.0015 zigbee rgb PWM module (dresden elektronik FLS-PP lp) Issue: pytradfri's set_hex_color() does not work for arbitrary colors with the current IKEA tradfri gateway. Only setting rgb hex values (param 5706) of some predefined colors has the desired effect. Others will fall back to one predefined value. I assume, the GW doesn't allow for values deviating from the predefined values. However, pytradfri's set_rgb_color() does also work for arbitrary colors. Latest pytradfri (2.2/PR51?) will convert rgb to xy and send xy thru the GW (param 5709 and 5710). -> changed the function used from set_hex_color() to set_rgb_color() in HA's component\light\tradfri Result: Full RGB support with arbitrary colors with my setup. Unfortunately I cannot test tradfri GW with other bulbs (no have hue/lightify bulbs). ___ Predefined colors from : this.f3891b = new HashMap(); this.f3891b.put("f5faf6", new C1386c(0.3804d, 0.3804d, "f5faf6", 0.54d)); this.f3891b.put("f1e0b5", new C1386c(0.4599d, 0.4106d, "CCT_LIGHT_NEUTRAL", 0.61d)); this.f3891b.put("efd275", new C1386c(0.5056d, 0.4152d, "efd275", 0.66d)); this.f3891b.put("dcf0f8", new C1386c(0.3221d, 0.3317d, "dcf0f8", 0.45d)); this.f3891b.put("eaf6fb", new C1386c(0.3451d, 0.3451d, "eaf6fb", 0.48d)); this.f3891b.put("f5faf6", new C1386c(0.3804d, 0.3804d, "f5faf6", 0.54d)); this.f3891b.put("f2eccf", new C1386c(0.4369d, 0.4041d, "f2eccf", 0.59d)); this.f3891b.put("CCT_LIGHT_NEUTRAL", new C1386c(0.4599d, 0.4106d, "CCT_LIGHT_NEUTRAL", 0.61d)); this.f3891b.put("efd275", new C1386c(0.5056d, 0.4152d, "efd275", 0.66d)); this.f3891b.put("ebb63e", new C1386c(0.5516d, 0.4075d, "ebb63e", 0.68d)); this.f3891b.put("e78834", new C1386c(0.58d, 0.38d, "e78834", 0.69d)); this.f3891b.put("e57345", new C1386c(0.58d, 0.35d, "e57345", 0.67d)); this.f3891b.put("da5d41", new C1386c(0.62d, 0.34d, "da5d41", 0.7d)); this.f3891b.put("dc4b31", new C1386c(0.66d, 0.32d, "dc4b31", 0.73d)); this.f3891b.put("e491af", new C1386c(0.5d, 0.28d, "e491af", 0.57d)); this.f3891b.put("e8bedd", new C1386c(0.45d, 0.28d, "e8bedd", 0.53d)); this.f3891b.put("d9337c", new C1386c(0.5d, 0.24d, "d9337c", 0.55d)); this.f3891b.put("c984bb", new C1386c(0.34d, 0.19d, "c984bb", 0.38d)); this.f3891b.put("8f2686", new C1386c(0.31d, 0.12d, "8f2686", 0.33d)); this.f3891b.put("4a418a", new C1386c(0.17d, 0.05d, "4a418a", 0.18d)); this.f3891b.put("6c83ba", new C1386c(0.2d, 0.1d, "6c83ba", 0.22d)); this.f3891b.put("a9d62b", new C1386c(0.4099999964237213d, 0.5099999904632568d, "a9d62b", 0.654d)); this.f3891b.put("d6e44b", new C1386c(0.44999998807907104d, 0.4699999988079071d, "d6e44b", 0.65d)); --- homeassistant/components/light/tradfri.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index fa21af996cb..0f56982dae5 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -179,8 +179,8 @@ class Tradfri(Light): self._api(self._light_control.set_state(True)) if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._api(self._light.light_control.set_hex_color( - color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]))) + self._api(self._light.light_control.set_rgb_color( + *kwargs[ATTR_RGB_COLOR])) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and self._ok_temps: From c94b3a7bf9341dbd03dae665cadaa1335087bcec Mon Sep 17 00:00:00 2001 From: Jay Stevens Date: Wed, 13 Sep 2017 22:27:12 -0700 Subject: [PATCH 179/277] Add support for Todoist platform (#9236) * Added basic Todoist support Creating a new platform for Todoist - https://todoist.com * Added more robust support for creating new custom projects. This means you can now specify things such as 'all tasks due today', 'all tasks due this week', etc. * Changed logging from warning to info. * Added label and comment support. * Added support for overdue tasks. * Changed logging to info instead of warning; fixed labels. * Added ability to filter projects by name. * Rename 'extra_projects' to 'custom_projects'. * Updated code to follow proper HASS style guidelines. * Got new_task service running. * Update .coveragerc. * Remove old try-catch block. This is left over from before we validated the inputs using the service schema. * Updated to use PLATFORM_SCHEMA. * Updated component to use Todoist API. * Removed commented-out code. This also removes functionality regarding finding out how many comments a task has. This functionality may be added back in the future. * Clarified TodoistProjectData, removed fetching comments. * Fixed bug where projects were grabbing all tasks. * Fixed bug where due dates were being ignored. * Removed debug logging. * Fixed linter errors. * Fixed Todoist docstring to be in line with HASS' style rules. * Organized imports. * Fixed voluptuous schema. * Moved ID lookups into . * Moved ID lookups into setup_platform. * Cleaned up setup_platform a bit. * Cleaned up Todoist service calls. * Changed debug logging level. * Fixed issue with configuration not validating. * Changed from storing the token to storing an API instance. * Use dict instead of Project object. * Updated to use list comprehension where possible. * Fixed linter errors. * Use constants instead of literals. * Changed logging to use old-style string formatting. * Removed unneeded caching. * Added comments explaining 'magic' strings. * Fixed bug where labels were always on the whitelist. * Fixed linter error. * Stopped checking whitelist length explicitly. --- .coveragerc | 1 + homeassistant/components/calendar/__init__.py | 1 + .../components/calendar/services.yaml | 19 + homeassistant/components/calendar/todoist.py | 544 ++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 568 insertions(+) create mode 100644 homeassistant/components/calendar/services.yaml create mode 100644 homeassistant/components/calendar/todoist.py diff --git a/.coveragerc b/.coveragerc index d5eb32e670c..2b96400d1e6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -247,6 +247,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 4e088c8a640..5198381b976 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -12,6 +12,7 @@ import re from homeassistant.components.google import ( CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.config_validation import time_period_str from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml new file mode 100644 index 00000000000..952e2302091 --- /dev/null +++ b/homeassistant/components/calendar/services.yaml @@ -0,0 +1,19 @@ +todoist: + new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. [Required] + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. [Optional] + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. [Optional] + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional] + example: 2 + due_date: + description: The day this task is due, in format YYYY-MM-DD. [Optional] + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py new file mode 100644 index 00000000000..ae9a1c9afa8 --- /dev/null +++ b/homeassistant/components/calendar/todoist.py @@ -0,0 +1,544 @@ +""" +Support for Todoist task management (https://todoist.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.todoist/ +""" + + +from datetime import datetime +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.components.google import ( + CONF_DEVICE_ID) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_ID, CONF_NAME, CONF_TOKEN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt +from homeassistant.util import Throttle + +REQUIREMENTS = ['todoist-python==7.0.17'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'todoist' + +# Calendar Platform: Does this calendar event last all day? +ALL_DAY = 'all_day' +# Attribute: All tasks in this project +ALL_TASKS = 'all_tasks' +# Todoist API: "Completed" flag -- 1 if complete, else 0 +CHECKED = 'checked' +# Attribute: Is this task complete? +COMPLETED = 'completed' +# Todoist API: What is this task about? +# Service Call: What is this task about? +CONTENT = 'content' +# Calendar Platform: Get a calendar event's description +DESCRIPTION = 'description' +# Calendar Platform: Used in the '_get_date()' method +DATETIME = 'dateTime' +# Attribute: When is this task due? +# Service Call: When is this task due? +DUE_DATE = 'due_date' +# Todoist API: Look up a task's due date +DUE_DATE_UTC = 'due_date_utc' +# Attribute: Is this task due today? +DUE_TODAY = 'due_today' +# Calendar Platform: When a calendar event ends +END = 'end' +# Todoist API: Look up a Project/Label/Task ID +ID = 'id' +# Todoist API: Fetch all labels +# Service Call: What are the labels attached to this task? +LABELS = 'labels' +# Todoist API: "Name" value +NAME = 'name' +# Attribute: Is this task overdue? +OVERDUE = 'overdue' +# Attribute: What is this task's priority? +# Todoist API: Get a task's priority +# Service Call: What is this task's priority? +PRIORITY = 'priority' +# Todoist API: Look up the Project ID a Task belongs to +PROJECT_ID = 'project_id' +# Service Call: What Project do you want a Task added to? +PROJECT_NAME = 'project' +# Todoist API: Fetch all Projects +PROJECTS = 'projects' +# Calendar Platform: When does a calendar event start? +START = 'start' +# Calendar Platform: What is the next calendar event about? +SUMMARY = 'summary' +# Todoist API: Fetch all Tasks +TASKS = 'items' + +SERVICE_NEW_TASK = 'new_task' +NEW_TASK_SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONTENT): cv.string, + vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), + vol.Optional(LABELS): cv.ensure_list_csv, + vol.Optional(PRIORITY): vol.All(vol.Coerce(int), + vol.Range(min=1, max=4)), + vol.Optional(DUE_DATE): cv.string +}) + +CONF_EXTRA_PROJECTS = 'custom_projects' +CONF_PROJECT_DUE_DATE = 'due_date_days' +CONF_PROJECT_WHITELIST = 'include_projects' +CONF_PROJECT_LABEL_WHITELIST = 'labels' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXTRA_PROJECTS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int), + vol.Optional(CONF_PROJECT_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]) + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Todoist platform.""" + # Check token: + token = config.get(CONF_TOKEN) + + # Look up IDs based on (lowercase) names. + project_id_lookup = {} + label_id_lookup = {} + + from todoist.api import TodoistAPI + api = TodoistAPI(token) + api.sync() + + # Setup devices: + # Grab all projects. + projects = api.state[PROJECTS] + + # Grab all labels + labels = api.state[LABELS] + + # Add all Todoist-defined projects. + project_devices = [] + for project in projects: + # Project is an object, not a dict! + # Because of that, we convert what we need to a dict. + project_data = { + CONF_NAME: project[NAME], + CONF_ID: project[ID] + } + project_devices.append( + TodoistProjectDevice(hass, project_data, labels, api) + ) + # Cache the names so we can easily look up name->ID. + project_id_lookup[project[NAME].lower()] = project[ID] + + # Cache all label names + for label in labels: + label_id_lookup[label[NAME].lower()] = label[ID] + + # Check config for more projects. + extra_projects = config.get(CONF_EXTRA_PROJECTS) + for project in extra_projects: + # Special filter: By date + project_due_date = project.get(CONF_PROJECT_DUE_DATE) + + # Special filter: By label + project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) + + # Special filter: By name + # Names must be converted into IDs. + project_name_filter = project.get(CONF_PROJECT_WHITELIST) + project_id_filter = [ + project_id_lookup[project_name.lower()] + for project_name in project_name_filter] + + # Create the custom project and add it to the devices array. + project_devices.append( + TodoistProjectDevice( + hass, project, labels, api, project_due_date, + project_label_filter, project_id_filter + ) + ) + + add_devices(project_devices) + + # Services: + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def handle_new_task(call): + """Called when a user creates a new Todoist Task from HASS.""" + project_name = call.data[PROJECT_NAME] + project_id = project_id_lookup[project_name] + + # Create the task + item = api.items.add(call.data[CONTENT], project_id) + + if LABELS in call.data: + task_labels = call.data[LABELS] + label_ids = [ + label_id_lookup[label.lower()] + for label in task_labels] + item.update(labels=label_ids) + + if PRIORITY in call.data: + item.update(priority=call.data[PRIORITY]) + + if DUE_DATE in call.data: + due_date = dt.parse_datetime(call.data[DUE_DATE]) + if due_date is None: + due = dt.parse_date(call.data[DUE_DATE]) + due_date = datetime(due.year, due.month, due.day) + # Format it in the manner Todoist expects + due_date = dt.as_utc(due_date) + date_format = '%Y-%m-%dT%H:%M' + due_date = datetime.strftime(due_date, date_format) + item.update(due_date_utc=due_date) + # Commit changes + api.commit() + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, + descriptions[DOMAIN][SERVICE_NEW_TASK], + schema=NEW_TASK_SERVICE_SCHEMA) + + +class TodoistProjectDevice(CalendarEventDevice): + """A device for getting the next Task from a Todoist Project.""" + + def __init__(self, hass, data, labels, token, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Create the Todoist Calendar Event Device.""" + self.data = TodoistProjectData( + data, labels, token, latest_task_due_date, + whitelisted_labels, whitelisted_projects + ) + + # Set up the calendar side of things + calendar_format = { + CONF_NAME: data[CONF_NAME], + # Set Entity ID to use the name so we can identify calendars + CONF_DEVICE_ID: data[CONF_NAME] + } + + super().__init__(hass, calendar_format) + + def update(self): + """Update all Todoist Calendars.""" + # Set basic calendar data + super().update() + + # Set Todoist-specific data that can't easily be grabbed + self._cal_data[ALL_TASKS] = [ + task[SUMMARY] for task in self.data.all_project_tasks] + + def cleanup(self): + """Clean up all calendar data.""" + super().cleanup() + self._cal_data[ALL_TASKS] = [] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + + # Add additional attributes. + attributes[DUE_TODAY] = self.data.event[DUE_TODAY] + attributes[OVERDUE] = self.data.event[OVERDUE] + attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] + attributes[PRIORITY] = self.data.event[PRIORITY] + attributes[LABELS] = self.data.event[LABELS] + + return attributes + + +class TodoistProjectData(object): + """ + Class used by the Task Device service object to hold all Todoist Tasks. + + This is analagous to the GoogleCalendarData found in the Google Calendar + component. + + Takes an object with a 'name' field and optionally an 'id' field (either + user-defined or from the Todoist API), a Todoist API token, and an optional + integer specifying the latest number of days from now a task can be due (7 + means everything due in the next week, 0 means today, etc.). + + This object has an exposed 'event' property (used by the Calendar platform + to determine the next calendar event) and an exposed 'update' method (used + by the Calendar platform to poll for new calendar events). + + The 'event' is a representation of a Todoist Task, with defined parameters + of 'due_today' (is the task due today?), 'all_day' (does the task have a + due date?), 'task_labels' (all labels assigned to the task), 'message' + (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing + to the task on the Todoist website), 'end_time' (what time the event is + due), 'start_time' (what time this event was last updated), 'overdue' (is + the task past its due date?), 'priority' (1-4, how important the task is, + with 4 being the most important), and 'all_tasks' (all tasks in this + project, sorted by how important they are). + + 'offset_reached', 'location', and 'friendly_name' are defined by the + platform itself, but are not used by this component at all. + + The 'update' method polls the Todoist API for new projects/tasks, as well + as any updates to current projects/tasks. This is throttled to every + MIN_TIME_BETWEEN_UPDATES minutes. + """ + + def __init__(self, project_data, labels, api, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Initialize a Todoist Project.""" + self.event = None + + self._api = api + self._name = project_data.get(CONF_NAME) + # If no ID is defined, fetch all tasks. + self._id = project_data.get(CONF_ID) + + # All labels the user has defined, for easy lookup. + self._labels = labels + # Not tracked: order, indent, comment_count. + + self.all_project_tasks = [] + + # The latest date a task can be due (for making lists of everything + # due today, or everything due in the next week, for example). + if latest_task_due_date is not None: + self._latest_due_date = dt.utcnow() + timedelta( + days=latest_task_due_date) + else: + self._latest_due_date = None + + # Only tasks with one of these labels will be included. + if whitelisted_labels is not None: + self._label_whitelist = whitelisted_labels + else: + self._label_whitelist = [] + + # This project includes only projects with these names. + if whitelisted_projects is not None: + self._project_id_whitelist = whitelisted_projects + else: + self._project_id_whitelist = [] + + def create_todoist_task(self, data): + """ + Create a dictionary based on a Task passed from the Todoist API. + + Will return 'None' if the task is to be filtered out. + """ + task = {} + # Fields are required to be in all returned task objects. + task[SUMMARY] = data[CONTENT] + task[COMPLETED] = data[CHECKED] == 1 + task[PRIORITY] = data[PRIORITY] + task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format( + data[ID]) + + # All task Labels (optional parameter). + task[LABELS] = [ + label[NAME].lower() for label in self._labels + if label[ID] in data[LABELS]] + + if self._label_whitelist and ( + not any(label in task[LABELS] + for label in self._label_whitelist)): + # We're not on the whitelist, return invalid task. + return None + + # Due dates (optional parameter). + # The due date is the END date -- the task cannot be completed + # past this time. + # That means that the START date is the earliest time one can + # complete the task. + # Generally speaking, that means right now. + task[START] = dt.utcnow() + if data[DUE_DATE_UTC] is not None: + due_date = data[DUE_DATE_UTC] + + # Due dates are represented in RFC3339 format, in UTC. + # Home Assistant exclusively uses UTC, so it'll + # handle the conversion. + time_format = '%a %d %b %Y %H:%M:%S %z' + # HASS' built-in parse time function doesn't like + # Todoist's time format; strptime has to be used. + task[END] = datetime.strptime(due_date, time_format) + + if self._latest_due_date is not None and ( + task[END] > self._latest_due_date): + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = task[END].date() == datetime.today().date() + + # Special case: Task is overdue. + if task[END] <= task[START]: + task[OVERDUE] = True + # Set end time to the current time plus 1 hour. + # We're pretty much guaranteed to update within that 1 hour, + # so it should be fine. + task[END] = task[START] + timedelta(hours=1) + else: + task[OVERDUE] = False + else: + # If we ask for everything due before a certain date, don't count + # things which have no due dates. + if self._latest_due_date is not None: + return None + + # Define values for tasks without due dates + task[END] = None + task[ALL_DAY] = True + task[DUE_TODAY] = False + task[OVERDUE] = False + + # Not tracked: id, comments, project_id order, indent, recurring. + return task + + @staticmethod + def select_best_task(project_tasks): + """ + Search through a list of events for the "best" event to select. + + The "best" event is determined by the following criteria: + * A proposed event must not be completed + * A proposed event must have a end date (otherwise we go with + the event at index 0, selected above) + * A proposed event must be on the same day or earlier as our + current event + * If a proposed event is an earlier day than what we have so + far, select it + * If a proposed event is on the same day as our current event + and the proposed event has a higher priority than our current + event, select it + * If a proposed event is on the same day as our current event, + has the same priority as our current event, but is due earlier + in the day, select it + """ + # Start at the end of the list, so if tasks don't have a due date + # the newest ones are the most important. + + event = project_tasks[-1] + + for proposed_event in project_tasks: + if event == proposed_event: + continue + if proposed_event[COMPLETED]: + # Event is complete! + continue + if proposed_event[END] is None: + # No end time: + if event[END] is None and ( + proposed_event[PRIORITY] < event[PRIORITY]): + # They also have no end time, + # but we have a higher priority. + event = proposed_event + continue + else: + continue + elif event[END] is None: + # We have an end time, they do not. + event = proposed_event + continue + if proposed_event[END].date() > event[END].date(): + # Event is too late. + continue + elif proposed_event[END].date() < event[END].date(): + # Event is earlier than current, select it. + event = proposed_event + continue + else: + if proposed_event[PRIORITY] > event[PRIORITY]: + # Proposed event has a higher priority. + event = proposed_event + continue + elif proposed_event[PRIORITY] == event[PRIORITY] and ( + proposed_event[END] < event[END]): + event = proposed_event + continue + return event + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + # If we have no data, we can just return right away. + if not project_task_data: + self.event = None + return True + + # Keep an updated list of all tasks in this project. + project_tasks = [] + + for task in project_task_data: + todoist_task = self.create_todoist_task(task) + if todoist_task is not None: + # A None task means it is invalid for this project + project_tasks.append(todoist_task) + + if not project_tasks: + # We had no valid tasks + return True + + # Organize the best tasks (so users can see all the tasks + # they have, organized) + while len(project_tasks) > 0: + best_task = self.select_best_task(project_tasks) + _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) + project_tasks.remove(best_task) + self.all_project_tasks.append(best_task) + + self.event = self.all_project_tasks[0] + + # Convert datetime to a string again + if self.event is not None: + if self.event[START] is not None: + self.event[START] = { + DATETIME: self.event[START].strftime(DATE_STR_FORMAT) + } + if self.event[END] is not None: + self.event[END] = { + DATETIME: self.event[END].strftime(DATE_STR_FORMAT) + } + else: + # HASS gets cranky if a calendar event never ends + # Let's set our "due date" to tomorrow + self.event[END] = { + DATETIME: ( + datetime.utcnow() + + timedelta(days=1) + ).strftime(DATE_STR_FORMAT) + } + _LOGGER.debug("Updated %s", self._name) + return True diff --git a/requirements_all.txt b/requirements_all.txt index 4473afdf37a..543adbd4f6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -961,6 +961,9 @@ thingspeak==0.4.1 # homeassistant.components.light.tikteck tikteck==0.4 +# homeassistant.components.calendar.todoist +todoist-python==7.0.17 + # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.11 From ba5e8d133d8b66b5799a2c9d16c5972f320c652c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Thu, 14 Sep 2017 07:30:29 +0200 Subject: [PATCH 180/277] Fix artwork bug in Apple TV (#9415) * Fix artwork bug in Apple TV * Clean up some None checks --- .../components/media_player/apple_tv.py | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 5deb4cd8dd5..6bd962ef443 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -93,7 +93,7 @@ class AppleTvDevice(MediaPlayerDevice): if not self._power.turned_on: return STATE_OFF - if self._playing is not None: + if self._playing: from pyatv import const state = self._playing.play_state if state == const.PLAY_STATE_NO_MEDIA or \ @@ -131,7 +131,7 @@ class AppleTvDevice(MediaPlayerDevice): @property def media_content_type(self): """Content type of current playing media.""" - if self._playing is not None: + if self._playing: from pyatv import const media_type = self._playing.media_type if media_type == const.MEDIA_TYPE_VIDEO: @@ -144,13 +144,13 @@ class AppleTvDevice(MediaPlayerDevice): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._playing is not None: + if self._playing: return self._playing.total_time @property def media_position(self): """Position of current playing media in seconds.""" - if self._playing is not None: + if self._playing: return self._playing.position @property @@ -168,18 +168,23 @@ class AppleTvDevice(MediaPlayerDevice): @property def media_image_hash(self): """Hash value for media image.""" - if self._playing is not None and self.state != STATE_IDLE: + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: return self._playing.hash @asyncio.coroutine def async_get_media_image(self): """Fetch media image of current playing image.""" - return (yield from self.atv.metadata.artwork()), 'image/png' + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return (yield from self.atv.metadata.artwork()), 'image/png' + + return None, None @property def media_title(self): """Title of current playing media.""" - if self._playing is not None: + if self._playing: if self.state == STATE_IDLE: return 'Nothing playing' title = self._playing.title @@ -215,7 +220,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: state = self.state if state == STATE_PAUSED: return self.atv.remote_control.play() @@ -227,7 +232,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.play() def async_media_stop(self): @@ -235,7 +240,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.stop() def async_media_pause(self): @@ -243,7 +248,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.pause() def async_media_next_track(self): @@ -251,7 +256,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.next() def async_media_previous_track(self): @@ -259,7 +264,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.previous() def async_media_seek(self, position): @@ -267,5 +272,5 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.set_position(position) From 5db55b306e02d764960070c8e1cc298289b05723 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 14 Sep 2017 08:18:22 +0200 Subject: [PATCH 181/277] Bump python-mirobo for improved device support and introduce API changes. (#9424) --- .../components/light/xiaomi_philipslight.py | 16 ++++++++-------- homeassistant/components/vacuum/xiaomi.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_philipslight.py index 8df25153a73..a6cd77028cb 100644 --- a/homeassistant/components/light/xiaomi_philipslight.py +++ b/homeassistant/components/light/xiaomi_philipslight.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-mirobo==0.1.3'] +REQUIREMENTS = ['python-mirobo==0.2.0'] # The light does not accept cct values < 1 CCT_MIN = 1 @@ -163,7 +163,7 @@ class XiaomiPhilipsLight(Light): result = yield from self._try_command( "Setting brightness failed: %s", - self._light.set_bright, percent_brightness) + self._light.set_brightness, percent_brightness) if result: self._brightness = brightness @@ -181,7 +181,7 @@ class XiaomiPhilipsLight(Light): result = yield from self._try_command( "Setting color temperature failed: %s cct", - self._light.set_cct, percent_color_temp) + self._light.set_color_temperature, percent_color_temp) if result: self._color_temp = color_temp @@ -207,13 +207,13 @@ class XiaomiPhilipsLight(Light): from mirobo import DeviceException try: state = yield from self.hass.async_add_job(self._light.status) - _LOGGER.debug("Got new state: %s", state.data) + _LOGGER.debug("Got new state: %s", state) self._state = state.is_on - self._brightness = int(255 * 0.01 * state.bright) - self._color_temp = self.translate(state.cct, CCT_MIN, CCT_MAX, - self.max_mireds, - self.min_mireds) + self._brightness = int(255 * 0.01 * state.brightness) + self._color_temp = self.translate(state.color_temperature, + CCT_MIN, CCT_MAX, + self.max_mireds, self.min_mireds) except DeviceException as ex: _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi.py index 95d7478aa9f..dad71796049 100644 --- a/homeassistant/components/vacuum/xiaomi.py +++ b/homeassistant/components/vacuum/xiaomi.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mirobo==0.1.3'] +REQUIREMENTS = ['python-mirobo==0.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 543adbd4f6a..114d0948f42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -754,7 +754,7 @@ python-juicenet==0.0.5 # homeassistant.components.light.xiaomi_philipslight # homeassistant.components.vacuum.xiaomi -python-mirobo==0.1.3 +python-mirobo==0.2.0 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 From f5dee2c27d18f896bc1acb732b96a231092a7cba Mon Sep 17 00:00:00 2001 From: pdanilew <24255269+pdanilew@users.noreply.github.com> Date: Thu, 14 Sep 2017 08:38:07 +0200 Subject: [PATCH 182/277] MPD small improvements (#9301) * Power button restored. * Added volume step and mute * Removed network operations from property + pylint made happy. --- homeassistant/components/media_player/mpd.py | 44 ++++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 55df1e367a4..44dd9a7ea29 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -11,12 +11,13 @@ import voluptuous as vol from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, - SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, - SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_PLAY, MEDIA_TYPE_PLAYLIST, + SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SHUFFLE_SET, - SUPPORT_SEEK, MediaPlayerDevice) + SUPPORT_SEEK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( - STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, + STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_PORT, CONF_PASSWORD, CONF_HOST, CONF_NAME) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -30,11 +31,11 @@ DEFAULT_PORT = 6600 PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120) -SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ +SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_MUTE | \ SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ SUPPORT_CLEAR_PLAYLIST | SUPPORT_SHUFFLE_SET | SUPPORT_SEEK | \ - SUPPORT_STOP + SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -74,6 +75,8 @@ class MpdDevice(MediaPlayerDevice): self._playlists = [] self._currentplaylist = None self._is_connected = False + self._muted = False + self._muted_volume = 0 # set up MPD client self._client = mpd.MPDClient() @@ -142,8 +145,15 @@ class MpdDevice(MediaPlayerDevice): return STATE_PLAYING elif self._status['state'] == 'pause': return STATE_PAUSED + elif self._status['state'] == 'stop': + return STATE_OFF - return STATE_ON + return STATE_OFF + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted @property def media_content_id(self): @@ -255,6 +265,15 @@ class MpdDevice(MediaPlayerDevice): """Service to send the MPD the command for previous track.""" self._client.previous() + def mute_volume(self, mute): + """Mute. Emulated with set_volume_level.""" + if mute is True: + self._muted_volume = self.volume_level + self.set_volume_level(0) + elif mute is False: + self.set_volume_level(self._muted_volume) + self._muted = mute + def play_media(self, media_type, media_id, **kwargs): """Send the media player the command for playing a playlist.""" _LOGGER.debug(str.format("Playing playlist: {0}", media_id)) @@ -282,6 +301,15 @@ class MpdDevice(MediaPlayerDevice): """Enable/disable shuffle mode.""" self._client.random(int(shuffle)) + def turn_off(self): + """Service to send the MPD the command to stop playing.""" + self._client.stop() + + def turn_on(self): + """Service to send the MPD the command to start playing.""" + self._client.play() + self._update_playlists(no_throttle=True) + def clear_playlist(self): """Clear players playlist.""" self._client.clear() From 3430c1c8bc2b541b29024b633855ebe4cf4ca651 Mon Sep 17 00:00:00 2001 From: giangvo Date: Thu, 14 Sep 2017 16:41:52 +1000 Subject: [PATCH 183/277] update broadlink.py to add support for MP1 switch (#9222) * update broadlink.py to add support for MP1 switch * fix code styles * fix code styles * optimize state fetching on mp1 * fix code styles * fix code styles * fix code styles * fix code styles * fix variable * remove default None * use string.format --- homeassistant/components/switch/broadlink.py | 116 ++++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 6ea738d82bc..c12d13860e2 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -14,9 +14,11 @@ import socket import voluptuous as vol from homeassistant.util.dt import utcnow +from homeassistant.util import Throttle from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_FRIENDLY_NAME, CONF_SWITCHES, CONF_COMMAND_OFF, CONF_COMMAND_ON, + CONF_FRIENDLY_NAME, CONF_SWITCHES, + CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_TIMEOUT, CONF_HOST, CONF_MAC, CONF_TYPE) import homeassistant.helpers.config_validation as cv @@ -24,20 +26,24 @@ REQUIREMENTS = ['broadlink==0.5'] _LOGGER = logging.getLogger(__name__) +TIME_BETWEEN_UPDATES = timedelta(seconds=5) + DOMAIN = 'broadlink' DEFAULT_NAME = 'Broadlink switch' DEFAULT_TIMEOUT = 10 DEFAULT_RETRY = 3 SERVICE_LEARN = 'learn_command' SERVICE_SEND = 'send_packet' +CONF_SLOTS = 'slots' RM_TYPES = ['rm', 'rm2', 'rm_mini', 'rm_pro_phicomm', 'rm2_home_plus', 'rm2_home_plus_gdt', 'rm2_pro_plus', 'rm2_pro_plus2', 'rm2_pro_plus_bl', 'rm_mini_shate'] SP1_TYPES = ['sp1'] SP2_TYPES = ['sp2', 'honeywell_sp2', 'sp3', 'spmini2', 'spminiplus'] +MP1_TYPES = ["mp1"] -SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES +SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES + MP1_TYPES SWITCH_SCHEMA = vol.Schema({ vol.Optional(CONF_COMMAND_OFF, default=None): cv.string, @@ -45,9 +51,17 @@ SWITCH_SCHEMA = vol.Schema({ vol.Optional(CONF_FRIENDLY_NAME): cv.string, }) +MP1_SWITCH_SLOT_SCHEMA = vol.Schema({ + vol.Optional('slot_1'): cv.string, + vol.Optional('slot_2'): cv.string, + vol.Optional('slot_3'): cv.string, + vol.Optional('slot_4'): cv.string +}) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SWITCHES, default={}): vol.Schema({cv.slug: SWITCH_SCHEMA}), + vol.Optional(CONF_SLOTS, default={}): MP1_SWITCH_SLOT_SCHEMA, vol.Required(CONF_HOST): cv.string, vol.Required(CONF_MAC): cv.string, vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, @@ -59,7 +73,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Broadlink switches.""" import broadlink - devices = config.get(CONF_SWITCHES, {}) + devices = config.get(CONF_SWITCHES) + slots = config.get('slots', {}) ip_addr = config.get(CONF_HOST) friendly_name = config.get(CONF_FRIENDLY_NAME) mac_addr = binascii.unhexlify( @@ -114,6 +129,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if retry == DEFAULT_RETRY-1: _LOGGER.error("Failed to send packet to device") + def _get_mp1_slot_name(switch_friendly_name, slot): + if not slots['slot_{}'.format(slot)]: + return '{} slot {}'.format(switch_friendly_name, slot) + return slots['slot_{}'.format(slot)] + if switch_type in RM_TYPES: broadlink_device = broadlink.rm((ip_addr, 80), mac_addr) hass.services.register(DOMAIN, SERVICE_LEARN + '_' + @@ -136,6 +156,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif switch_type in SP2_TYPES: broadlink_device = broadlink.sp2((ip_addr, 80), mac_addr) switches = [BroadlinkSP2Switch(friendly_name, broadlink_device)] + elif switch_type in MP1_TYPES: + switches = [] + broadlink_device = broadlink.mp1((ip_addr, 80), mac_addr) + parent_device = BroadlinkMP1Switch(broadlink_device) + for i in range(1, 5): + slot = BroadlinkMP1Slot( + _get_mp1_slot_name(friendly_name, i), + broadlink_device, i, parent_device) + switches.append(slot) broadlink_device.timeout = config.get(CONF_TIMEOUT) try: @@ -268,3 +297,84 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): if state is None and retry > 0: return self._update(retry-1) self._state = state + + +class BroadlinkMP1Slot(BroadlinkRMSwitch): + """Representation of a slot of Broadlink switch.""" + + def __init__(self, friendly_name, device, slot, parent_device): + """Initialize the slot of switch.""" + super().__init__(friendly_name, device, None, None) + self._command_on = 1 + self._command_off = 0 + self._slot = slot + self._parent_device = parent_device + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return False + + def _sendpacket(self, packet, retry=2): + """Send packet to device.""" + try: + self._device.set_power(self._slot, packet) + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error(error) + return False + if not self._auth(): + return False + return self._sendpacket(packet, max(0, retry-1)) + return True + + @property + def should_poll(self): + """Polling needed.""" + return True + + def update(self): + """Trigger update for all switches on the parent device.""" + self._parent_device.update() + self._state = self._parent_device.get_outlet_status(self._slot) + + +class BroadlinkMP1Switch(object): + """Representation of a Broadlink switch - To fetch states of all slots.""" + + def __init__(self, device): + """Initialize the switch.""" + self._device = device + self._states = None + + def get_outlet_status(self, slot): + """Get status of outlet from cached status list.""" + return self._states['s{}'.format(slot)] + + @Throttle(TIME_BETWEEN_UPDATES) + def update(self): + """Fetch new state data for this device.""" + self._update() + + def _update(self, retry=2): + try: + states = self._device.check_power() + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error(error) + return + if not self._auth(): + return + return self._update(max(0, retry-1)) + if states is None and retry > 0: + return self._update(max(0, retry-1)) + self._states = states + + def _auth(self, retry=2): + try: + auth = self._device.auth() + except socket.timeout: + auth = False + if not auth and retry > 0: + return self._auth(retry-1) + return auth From 371d1cc87265a86d98804a6f8a37eaac9db26a78 Mon Sep 17 00:00:00 2001 From: rollbrettler Date: Thu, 14 Sep 2017 10:13:01 +0200 Subject: [PATCH 184/277] Fix displaying of friendly_name for light template component (#9413) --- homeassistant/components/light/template.py | 5 +++ tests/components/light/test_template.py | 38 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index f630625746e..26ae0517955 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -127,6 +127,11 @@ class LightTemplate(Light): """Return the brightness of the light.""" return self._brightness + @property + def name(self): + """Return the display name of this light.""" + return self._name + @property def supported_features(self): """Flag supported features.""" diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py index 6564d66299b..0e741cc7ee1 100644 --- a/tests/components/light/test_template.py +++ b/tests/components/light/test_template.py @@ -590,6 +590,44 @@ class TestTemplateLight: assert state.attributes.get('brightness') == '42' + def test_friendly_name(self): + """Test the accessibility of the friendly_name attribute.""" + with assert_setup_component(1, 'light'): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'friendly_name': '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 is not None + + assert state.attributes.get('friendly_name') == 'Template light' + @asyncio.coroutine def test_restore_state(hass): From 20f3e3dcf976f10e2699a90a971626f895e402e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20RAMAGE?= Date: Thu, 14 Sep 2017 16:52:47 +0200 Subject: [PATCH 185/277] Improve Python script (#9417) * add datetime and support for unpacksequence add datetime to builtin and support for unpacksequence a,b = (1,2) for a,b in [(1,2),(3,4)] * add test for python_script * fix test * restore previous test restore previous tests, removed by mistake sorry... * fix test * Update test_python_script.py * fix travis * fix test * Update test_python_script.py * Add files via upload * fix travis... --- homeassistant/components/python_script.py | 9 +++++++-- tests/components/test_python_script.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 386abba59ae..f80dea83944 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -2,6 +2,7 @@ import glob import os import logging +import datetime import voluptuous as vol @@ -63,7 +64,8 @@ def execute_script(hass, name, data=None): def execute(hass, filename, source, data=None): """Execute Python source.""" from RestrictedPython import compile_restricted_exec - from RestrictedPython.Guards import safe_builtins, full_write_guard + from RestrictedPython.Guards import safe_builtins, full_write_guard, \ + guarded_iter_unpack_sequence, guarded_unpack_sequence from RestrictedPython.Utilities import utility_builtins from RestrictedPython.Eval import default_guarded_getitem @@ -94,13 +96,16 @@ def execute(hass, filename, source, data=None): builtins = safe_builtins.copy() builtins.update(utility_builtins) + builtins['datetime'] = datetime restricted_globals = { '__builtins__': builtins, '_print_': StubPrinter, '_getattr_': protected_getattr, '_write_': full_write_guard, '_getiter_': iter, - '_getitem_': default_guarded_getitem + '_getitem_': default_guarded_getitem, + '_iter_unpack_sequence_': guarded_iter_unpack_sequence, + '_unpack_sequence_': guarded_unpack_sequence, } logger = logging.getLogger('{}.{}'.format(__name__, filename)) local = { diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index 62c1b67eba9..3ff32cc312a 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -180,3 +180,26 @@ for i in [1, 2]: assert hass.states.is_state('hello.1', 'world') assert hass.states.is_state('hello.2', 'world') + + +@asyncio.coroutine +def test_unpacking_sequence(hass, caplog): + """Test compile error logs error.""" + caplog.set_level(logging.ERROR) + source = """ +a,b = (1,2) +ab_list = [(a,b) for a,b in [(1, 2), (3, 4)]] +hass.states.set('hello.a', a) +hass.states.set('hello.b', b) +hass.states.set('hello.ab_list', '{}'.format(ab_list)) +""" + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert hass.states.is_state('hello.a', '1') + assert hass.states.is_state('hello.b', '2') + assert hass.states.is_state('hello.ab_list', '[(1, 2), (3, 4)]') + + # No errors logged = good + assert caplog.text == '' From 9c603d932dfe77ae8b985f5918528b6cef6b4e99 Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Thu, 14 Sep 2017 11:08:45 -0700 Subject: [PATCH 186/277] Add manual alarm_control_panel pending time per state (#9264) * - Enhanced manual alarm_control_panel config so that you can specify different pending time for different alarm state * - Fixed demo alaram control panel * - Updated configuration structure for state specific pending times * - Addressed comment * Address code review comments * - Fixed failing tests - Updated demo alarm component to use new per state pending_time setting * - Removing previously added comment which might have caused build to fail? * - moved "copy.deepcopy(config)" out of loop so config is only copied once --- .../components/alarm_control_panel/demo.py | 18 ++- .../components/alarm_control_panel/manual.py | 105 ++++++++------ .../alarm_control_panel/test_manual.py | 131 ++++++++++++++++++ 3 files changed, 210 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index 8ebf0a93c38..00dae5c2779 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -5,10 +5,26 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ import homeassistant.components.alarm_control_panel.manual as manual +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED, CONF_PENDING_TIME) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo alarm control panel platform.""" add_devices([ - manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False), + manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False, { + STATE_ALARM_ARMED_AWAY: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_HOME: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_NIGHT: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_TRIGGERED: { + CONF_PENDING_TIME: 5 + }, + }), ]) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index f345ccc4dcd..237959ab10d 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -4,6 +4,7 @@ Support for manual alarms. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.manual/ """ +import copy import datetime import logging @@ -24,9 +25,28 @@ DEFAULT_PENDING_TIME = 60 DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False +SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] + ATTR_POST_PENDING_STATE = 'post_pending_state' -PLATFORM_SCHEMA = vol.Schema({ + +def _state_validator(config): + config = copy.deepcopy(config) + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +STATE_SETTING_SCHEMA = vol.Schema({ + vol.Optional(CONF_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + + +PLATFORM_SCHEMA = vol.Schema(vol.All({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -36,7 +56,11 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, -}) + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, +}, _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -49,7 +73,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_CODE), config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), - config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER) + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config )]) @@ -63,19 +88,23 @@ class ManualAlarm(alarm.AlarmControlPanel): or disarm if `disarm_after_trigger` is true. """ - def __init__(self, hass, name, code, pending_time, - trigger_time, disarm_after_trigger): + def __init__(self, hass, name, code, pending_time, trigger_time, + disarm_after_trigger, config): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass self._name = name self._code = str(code) if code else None - self._pending_time = datetime.timedelta(seconds=pending_time) self._trigger_time = datetime.timedelta(seconds=trigger_time) self._disarm_after_trigger = disarm_after_trigger self._pre_trigger_state = self._state self._state_ts = None + self._pending_time_by_state = {} + for state in SUPPORTED_PENDING_STATES: + self._pending_time_by_state[state] = datetime.timedelta( + seconds=config[state][CONF_PENDING_TIME]) + @property def should_poll(self): """Return the plling state.""" @@ -89,17 +118,10 @@ class ManualAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + + elif (self._state_ts + self._pending_time_by_state[self._state] + self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED @@ -107,8 +129,16 @@ class ManualAlarm(alarm.AlarmControlPanel): self._state = self._pre_trigger_state return self._state + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING + return self._state + def _within_pending_time(self, state): + pending_time = self._pending_time_by_state[state] + return self._state_ts + pending_time > dt_util.utcnow() + @property def code_format(self): """One or more characters.""" @@ -128,58 +158,47 @@ class ManualAlarm(alarm.AlarmControlPanel): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return - self._state = STATE_ALARM_ARMED_NIGHT - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time_by_state[state] + + if state == STATE_ALARM_TRIGGERED and self._trigger_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + self._trigger_time + pending_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index 063f3361148..b5af01584d3 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -352,6 +352,137 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_armed_home_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_home': { + 'pending_time': 2 + } + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_home(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + def test_armed_away_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_away': { + 'pending_time': 2 + } + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_away(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_armed_night_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_night': { + 'pending_time': 2 + } + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_night(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'triggered': { + 'pending_time': 2 + }, + 'trigger_time': 3, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_with_disarm_after_trigger(self): """Test disarm after trigger.""" self.assertTrue(setup_component( From 4126b8bd139f391756d16436bb893355b327491c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 14 Sep 2017 18:49:03 -0400 Subject: [PATCH 187/277] Rename xiaomi #9425 (#9426) * rename xiaomi to xiaomi_aqara * rename xiaomi vacuum and xiaomi phillips light to xiaomi miio * update discovery and tests * style * update discovery and tests * Still use Philips as name --- .coveragerc | 14 +++++++------- .../binary_sensor/{xiaomi.py => xiaomi_aqara.py} | 5 +++-- .../cover/{xiaomi.py => xiaomi_aqara.py} | 3 ++- homeassistant/components/discovery.py | 2 +- .../light/{xiaomi.py => xiaomi_aqara.py} | 3 ++- .../{xiaomi_philipslight.py => xiaomi_miio.py} | 2 +- .../sensor/{xiaomi.py => xiaomi_aqara.py} | 5 +++-- .../switch/{xiaomi.py => xiaomi_aqara.py} | 5 +++-- homeassistant/components/vacuum/roomba.py | 2 +- .../vacuum/{xiaomi.py => xiaomi_miio.py} | 4 ++-- .../components/{xiaomi.py => xiaomi_aqara.py} | 2 +- requirements_all.txt | 6 +++--- .../vacuum/{test_xiaomi.py => test_xiaomi_miio.py} | 2 +- 13 files changed, 30 insertions(+), 25 deletions(-) rename homeassistant/components/binary_sensor/{xiaomi.py => xiaomi_aqara.py} (98%) rename homeassistant/components/cover/{xiaomi.py => xiaomi_aqara.py} (94%) rename homeassistant/components/light/{xiaomi.py => xiaomi_aqara.py} (95%) rename homeassistant/components/light/{xiaomi_philipslight.py => xiaomi_miio.py} (99%) rename homeassistant/components/sensor/{xiaomi.py => xiaomi_aqara.py} (94%) rename homeassistant/components/switch/{xiaomi.py => xiaomi_aqara.py} (96%) rename homeassistant/components/vacuum/{xiaomi.py => xiaomi_miio.py} (99%) rename homeassistant/components/{xiaomi.py => xiaomi_aqara.py} (99%) rename tests/components/vacuum/{test_xiaomi.py => test_xiaomi_miio.py} (99%) diff --git a/.coveragerc b/.coveragerc index 2b96400d1e6..274d6260078 100644 --- a/.coveragerc +++ b/.coveragerc @@ -208,12 +208,12 @@ omit = homeassistant/components/wink.py homeassistant/components/*/wink.py - homeassistant/components/xiaomi.py - homeassistant/components/binary_sensor/xiaomi.py - homeassistant/components/cover/xiaomi.py - homeassistant/components/light/xiaomi.py - homeassistant/components/sensor/xiaomi.py - homeassistant/components/switch/xiaomi.py + homeassistant/components/xiaomi_aqara.py + homeassistant/components/binary_sensor/xiaomi_aqara.py + homeassistant/components/cover/xiaomi_aqara.py + homeassistant/components/light/xiaomi_aqara.py + homeassistant/components/sensor/xiaomi_aqara.py + homeassistant/components/switch/xiaomi_aqara.py homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py @@ -332,7 +332,7 @@ omit = homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py - homeassistant/components/light/xiaomi_philipslight.py + homeassistant/components/light/xiaomi_miio.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py similarity index 98% rename from homeassistant/components/binary_sensor/xiaomi.py rename to homeassistant/components/binary_sensor/xiaomi_aqara.py index c5f0a7b3dce..d60d265b849 100644 --- a/homeassistant/components/binary_sensor/xiaomi.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -1,8 +1,9 @@ -"""Support for Xiaomi binary sensors.""" +"""Support for Xiaomi aqara binary sensors.""" import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi_aqara.py similarity index 94% rename from homeassistant/components/cover/xiaomi.py rename to homeassistant/components/cover/xiaomi_aqara.py index d0e7bfa6d7e..17d056a5010 100644 --- a/homeassistant/components/cover/xiaomi.py +++ b/homeassistant/components/cover/xiaomi_aqara.py @@ -2,7 +2,8 @@ import logging from homeassistant.components.cover import CoverDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 1f8b12eef6b..439b6258bcd 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -45,7 +45,7 @@ SERVICE_HANDLERS = { SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), - SERVICE_XIAOMI_GW: ('xiaomi', None), + SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), 'philips_hue': ('light', 'hue'), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), diff --git a/homeassistant/components/light/xiaomi.py b/homeassistant/components/light/xiaomi_aqara.py similarity index 95% rename from homeassistant/components/light/xiaomi.py rename to homeassistant/components/light/xiaomi_aqara.py index d8a70b726f4..63770fbf9b7 100755 --- a/homeassistant/components/light/xiaomi.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -2,7 +2,8 @@ import logging import struct import binascii -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, Light) diff --git a/homeassistant/components/light/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_miio.py similarity index 99% rename from homeassistant/components/light/xiaomi_philipslight.py rename to homeassistant/components/light/xiaomi_miio.py index a6cd77028cb..cebd1670c4a 100644 --- a/homeassistant/components/light/xiaomi_philipslight.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import PlatformNotReady _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Philips Light' -PLATFORM = 'xiaomi_philipslight' +PLATFORM = 'xiaomi_miio' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), diff --git a/homeassistant/components/sensor/xiaomi.py b/homeassistant/components/sensor/xiaomi_aqara.py similarity index 94% rename from homeassistant/components/sensor/xiaomi.py rename to homeassistant/components/sensor/xiaomi_aqara.py index 994a6789bbf..e439691fd63 100644 --- a/homeassistant/components/sensor/xiaomi.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -1,7 +1,8 @@ -"""Support for Xiaomi sensors.""" +"""Support for Xiaomi aqara sensors.""" import logging -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) from homeassistant.const import TEMP_CELSIUS _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/xiaomi.py b/homeassistant/components/switch/xiaomi_aqara.py similarity index 96% rename from homeassistant/components/switch/xiaomi.py rename to homeassistant/components/switch/xiaomi_aqara.py index 767043a8bc9..67a56829bec 100644 --- a/homeassistant/components/switch/xiaomi.py +++ b/homeassistant/components/switch/xiaomi_aqara.py @@ -1,8 +1,9 @@ -"""Support for Xiaomi binary sensors.""" +"""Support for Xiaomi aqara binary sensors.""" import logging from homeassistant.components.switch import SwitchDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index cf9ee064283..37cd9d06785 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -98,7 +98,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class RoombaVacuum(VacuumDevice): - """Representation of a Xiaomi Vacuum cleaner robot.""" + """Representation of a Roomba Vacuum cleaner robot.""" def __init__(self, name, roomba): """Initialize the Roomba handler.""" diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi_miio.py similarity index 99% rename from homeassistant/components/vacuum/xiaomi.py rename to homeassistant/components/vacuum/xiaomi_miio.py index dad71796049..8e00c21877c 100644 --- a/homeassistant/components/vacuum/xiaomi.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -2,7 +2,7 @@ Support for the Xiaomi vacuum cleaner robot. For more details about this platform, please refer to the documentation -https://home-assistant.io/components/vacuum.xiaomi/ +https://home-assistant.io/components/vacuum.xiaomi_miio/ """ import asyncio from functools import partial @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Vacuum cleaner' ICON = 'mdi:google-circles-group' -PLATFORM = 'xiaomi' +PLATFORM = 'xiaomi_miio' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi_aqara.py similarity index 99% rename from homeassistant/components/xiaomi.py rename to homeassistant/components/xiaomi_aqara.py index ac197d2d942..f331ace06bd 100644 --- a/homeassistant/components/xiaomi.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -17,7 +17,7 @@ ATTR_RINGTONE_VOL = 'ringtone_vol' CONF_DISCOVERY_RETRY = 'discovery_retry' CONF_GATEWAYS = 'gateways' CONF_INTERFACE = 'interface' -DOMAIN = 'xiaomi' +DOMAIN = 'xiaomi_aqara' PY_XIAOMI_GATEWAY = "xiaomi_gw" diff --git a/requirements_all.txt b/requirements_all.txt index 114d0948f42..edc0a95f0e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -298,7 +298,7 @@ holidays==0.8.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a -# homeassistant.components.xiaomi +# homeassistant.components.xiaomi_aqara https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.2.zip#PyXiaomiGateway==0.3.2 # homeassistant.components.sensor.dht @@ -752,8 +752,8 @@ python-juicenet==0.0.5 # homeassistant.components.lirc # python-lirc==1.2.3 -# homeassistant.components.light.xiaomi_philipslight -# homeassistant.components.vacuum.xiaomi +# homeassistant.components.light.xiaomi_miio +# homeassistant.components.vacuum.xiaomi_miio python-mirobo==0.2.0 # homeassistant.components.media_player.mpd diff --git a/tests/components/vacuum/test_xiaomi.py b/tests/components/vacuum/test_xiaomi_miio.py similarity index 99% rename from tests/components/vacuum/test_xiaomi.py rename to tests/components/vacuum/test_xiaomi_miio.py index 0045bbb3b24..2693eaef833 100644 --- a/tests/components/vacuum/test_xiaomi.py +++ b/tests/components/vacuum/test_xiaomi_miio.py @@ -11,7 +11,7 @@ from homeassistant.components.vacuum import ( SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_RETURN_TO_BASE, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, SERVICE_START_PAUSE, SERVICE_STOP, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON) -from homeassistant.components.vacuum.xiaomi import ( +from homeassistant.components.vacuum.xiaomi_miio import ( ATTR_CLEANED_AREA, ATTR_CLEANING_TIME, ATTR_DO_NOT_DISTURB, ATTR_ERROR, CONF_HOST, CONF_NAME, CONF_TOKEN, PLATFORM, SERVICE_MOVE_REMOTE_CONTROL, SERVICE_MOVE_REMOTE_CONTROL_STEP, From 1c8253f7627fff824c63d851215fecd7016da2bb Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Thu, 14 Sep 2017 20:37:51 -0400 Subject: [PATCH 188/277] Bump version of aioautomatic (#9435) --- homeassistant/components/device_tracker/automatic.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 6ae038fd41c..05fe0b6997d 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.2'] +REQUIREMENTS = ['aioautomatic==0.6.3'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index edc0a95f0e7..db0c4af85f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -42,7 +42,7 @@ TwitterAPI==2.4.6 abodepy==0.9.0 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.2 +aioautomatic==0.6.3 # homeassistant.components.sensor.dnsip aiodns==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e543a8eada..f2a398d96f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -27,7 +27,7 @@ PyJWT==1.5.3 SoCo==0.12 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.2 +aioautomatic==0.6.3 # homeassistant.components.emulated_hue # homeassistant.components.http From 0100af0fa6f67daf7aea881eed7e8c403caa6714 Mon Sep 17 00:00:00 2001 From: Martin Donlon Date: Fri, 15 Sep 2017 02:40:40 -0700 Subject: [PATCH 189/277] Fix russound_rio for python 3.4 (#9428) Bumped russound_rio dependency to 0.1.4 which includes a fix for python 3.4.2 (asyncio.async vs asyncio.ensure_future) --- homeassistant/components/media_player/russound_rio.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/russound_rio.py b/homeassistant/components/media_player/russound_rio.py index 743fc4e262d..31b04ceb3cd 100644 --- a/homeassistant/components/media_player/russound_rio.py +++ b/homeassistant/components/media_player/russound_rio.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['russound_rio==0.1.3'] +REQUIREMENTS = ['russound_rio==0.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index db0c4af85f5..d4177d5570c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -869,7 +869,7 @@ roombapy==1.3.1 russound==0.1.7 # homeassistant.components.media_player.russound_rio -russound_rio==0.1.3 +russound_rio==0.1.4 # homeassistant.components.media_player.yamaha rxv==0.4.0 From 175b4ae5e08e3550a39def2825cd77b2b37c5421 Mon Sep 17 00:00:00 2001 From: John Boiles Date: Fri, 15 Sep 2017 06:39:20 -0700 Subject: [PATCH 190/277] Basic MQTT vacuum support (#9386) * Basic MQTT vacuum support * PR feedback * Support for fan_speed and send_command services * Fix configurable topics * Use configurable bools for cleaning/docked/stopped state * Fix language in docstring * PR feedback * Remove duplicate vacuum/state topic defaults * Fix incorrect template for docked value * Move direction like default mqtt platfom/components * fix None on templates * fix tests * fix int * fix tests * ready to merge --- .coveragerc | 1 + homeassistant/components/vacuum/demo.py | 6 +- homeassistant/components/vacuum/mqtt.py | 498 ++++++++++++++++++++++++ tests/components/vacuum/test_mqtt.py | 199 ++++++++++ 4 files changed, 701 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/vacuum/mqtt.py create mode 100644 tests/components/vacuum/test_mqtt.py diff --git a/.coveragerc b/.coveragerc index 274d6260078..0ed94e62199 100644 --- a/.coveragerc +++ b/.coveragerc @@ -582,6 +582,7 @@ omit = homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py homeassistant/components/zwave/util.py + homeassistant/components/vacuum/mqtt.py [report] diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index 54415b59db0..668e3ca37e6 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -142,7 +142,7 @@ class DemoVacuum(VacuumDevice): self.schedule_update_ha_state() def stop(self, **kwargs): - """Turn the vacuum off.""" + """Stop the vacuum.""" if self.supported_features & SUPPORT_STOP == 0: return @@ -162,7 +162,7 @@ class DemoVacuum(VacuumDevice): self.schedule_update_ha_state() def locate(self, **kwargs): - """Turn the vacuum off.""" + """Locate the vacuum (usually by playing a song).""" if self.supported_features & SUPPORT_LOCATE == 0: return @@ -184,7 +184,7 @@ class DemoVacuum(VacuumDevice): self.schedule_update_ha_state() def set_fan_speed(self, fan_speed, **kwargs): - """Tell the vacuum to return to its dock.""" + """Set the vacuum's fan speed.""" if self.supported_features & SUPPORT_FAN_SPEED == 0: return diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py new file mode 100644 index 00000000000..853c50369a2 --- /dev/null +++ b/homeassistant/components/vacuum/mqtt.py @@ -0,0 +1,498 @@ +""" +Support for a generic MQTT vacuum. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/vacuum.mqtt/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +import homeassistant.helpers.config_validation as cv +from homeassistant.components.vacuum import ( + DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, + SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, + SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + VacuumDevice) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME +from homeassistant.core import callback +from homeassistant.util.icon import icon_for_battery_level + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +SERVICE_TO_STRING = { + SUPPORT_TURN_ON: 'turn_on', + SUPPORT_TURN_OFF: 'turn_off', + SUPPORT_PAUSE: 'pause', + SUPPORT_STOP: 'stop', + SUPPORT_RETURN_HOME: 'return_home', + SUPPORT_FAN_SPEED: 'fan_speed', + SUPPORT_BATTERY: 'battery', + SUPPORT_STATUS: 'status', + SUPPORT_SEND_COMMAND: 'send_command', + SUPPORT_LOCATE: 'locate', + SUPPORT_CLEAN_SPOT: 'clean_spot', +} + +STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} + + +def services_to_strings(services): + """Convert SUPPORT_* service bitmask to list of service strings.""" + strings = [] + for service in SERVICE_TO_STRING: + if service & services: + strings.append(SERVICE_TO_STRING[service]) + return strings + + +def strings_to_services(strings): + """Convert service strings to SUPPORT_* service bitmask.""" + services = 0 + for string in strings: + services |= STRING_TO_SERVICE[string] + return services + + +DEFAULT_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STOP |\ + SUPPORT_RETURN_HOME | SUPPORT_STATUS | SUPPORT_BATTERY |\ + SUPPORT_CLEAN_SPOT +ALL_SERVICES = DEFAULT_SERVICES | SUPPORT_PAUSE | SUPPORT_LOCATE |\ + SUPPORT_FAN_SPEED | SUPPORT_SEND_COMMAND + +BOOL_TRUE_STRINGS = {'true', '1', 'yes', 'on'} + +CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES +CONF_PAYLOAD_TURN_ON = 'payload_turn_on' +CONF_PAYLOAD_TURN_OFF = 'payload_turn_off' +CONF_PAYLOAD_RETURN_TO_BASE = 'payload_return_to_base' +CONF_PAYLOAD_STOP = 'payload_stop' +CONF_PAYLOAD_CLEAN_SPOT = 'payload_clean_spot' +CONF_PAYLOAD_LOCATE = 'payload_locate' +CONF_PAYLOAD_START_PAUSE = 'payload_start_pause' +CONF_BATTERY_LEVEL_TOPIC = 'battery_level_topic' +CONF_BATTERY_LEVEL_TEMPLATE = 'battery_level_template' +CONF_CHARGING_TOPIC = 'charging_topic' +CONF_CHARGING_TEMPLATE = 'charging_template' +CONF_CLEANING_TOPIC = 'cleaning_topic' +CONF_CLEANING_TEMPLATE = 'cleaning_template' +CONF_DOCKED_TOPIC = 'docked_topic' +CONF_DOCKED_TEMPLATE = 'docked_template' +CONF_STATE_TOPIC = 'state_topic' +CONF_STATE_TEMPLATE = 'state_template' +CONF_FAN_SPEED_TOPIC = 'fan_speed_topic' +CONF_FAN_SPEED_TEMPLATE = 'fan_speed_template' +CONF_SET_FAN_SPEED_TOPIC = 'set_fan_speed_topic' +CONF_FAN_SPEED_LIST = 'fan_speed_list' +CONF_SEND_COMMAND_TOPIC = 'send_command_topic' + +DEFAULT_NAME = 'MQTT Vacuum' +DEFAULT_RETAIN = False +DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES) +DEFAULT_PAYLOAD_TURN_ON = 'turn_on' +DEFAULT_PAYLOAD_TURN_OFF = 'turn_off' +DEFAULT_PAYLOAD_RETURN_TO_BASE = 'return_to_base' +DEFAULT_PAYLOAD_STOP = 'stop' +DEFAULT_PAYLOAD_CLEAN_SPOT = 'clean_spot' +DEFAULT_PAYLOAD_LOCATE = 'locate' +DEFAULT_PAYLOAD_START_PAUSE = 'start_pause' + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS): + vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), + vol.Optional(mqtt.CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_PAYLOAD_TURN_ON, + default=DEFAULT_PAYLOAD_TURN_ON): cv.string, + vol.Optional(CONF_PAYLOAD_TURN_OFF, + default=DEFAULT_PAYLOAD_TURN_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_RETURN_TO_BASE, + default=DEFAULT_PAYLOAD_RETURN_TO_BASE): cv.string, + vol.Optional(CONF_PAYLOAD_STOP, + default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_PAYLOAD_CLEAN_SPOT, + default=DEFAULT_PAYLOAD_CLEAN_SPOT): cv.string, + vol.Optional(CONF_PAYLOAD_LOCATE, + default=DEFAULT_PAYLOAD_LOCATE): cv.string, + vol.Optional(CONF_PAYLOAD_START_PAUSE, + default=DEFAULT_PAYLOAD_START_PAUSE): cv.string, + vol.Optional(CONF_BATTERY_LEVEL_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_BATTERY_LEVEL_TEMPLATE): cv.template, + vol.Optional(CONF_CHARGING_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_CHARGING_TEMPLATE): cv.template, + vol.Optional(CONF_CLEANING_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_CLEANING_TEMPLATE): cv.template, + vol.Optional(CONF_DOCKED_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_DOCKED_TEMPLATE): cv.template, + vol.Optional(CONF_STATE_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_FAN_SPEED_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_SET_FAN_SPEED_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the vacuum.""" + name = config.get(CONF_NAME) + supported_feature_strings = config.get(CONF_SUPPORTED_FEATURES) + supported_features = strings_to_services(supported_feature_strings) + + qos = config.get(mqtt.CONF_QOS) + retain = config.get(mqtt.CONF_RETAIN) + + command_topic = config.get(mqtt.CONF_COMMAND_TOPIC) + payload_turn_on = config.get(CONF_PAYLOAD_TURN_ON) + payload_turn_off = config.get(CONF_PAYLOAD_TURN_OFF) + payload_return_to_base = config.get(CONF_PAYLOAD_RETURN_TO_BASE) + payload_stop = config.get(CONF_PAYLOAD_STOP) + payload_clean_spot = config.get(CONF_PAYLOAD_CLEAN_SPOT) + payload_locate = config.get(CONF_PAYLOAD_LOCATE) + payload_start_pause = config.get(CONF_PAYLOAD_START_PAUSE) + + battery_level_topic = config.get(CONF_BATTERY_LEVEL_TOPIC) + battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE) + if battery_level_template: + battery_level_template.hass = hass + + charging_topic = config.get(CONF_CHARGING_TOPIC) + charging_template = config.get(CONF_CHARGING_TEMPLATE) + if charging_template: + charging_template.hass = hass + + cleaning_topic = config.get(CONF_CLEANING_TOPIC) + cleaning_template = config.get(CONF_CLEANING_TEMPLATE) + if cleaning_template: + cleaning_template.hass = hass + + docked_topic = config.get(CONF_DOCKED_TOPIC) + docked_template = config.get(CONF_DOCKED_TEMPLATE) + if docked_template: + docked_template.hass = hass + + fan_speed_topic = config.get(CONF_FAN_SPEED_TOPIC) + fan_speed_template = config.get(CONF_FAN_SPEED_TEMPLATE) + if fan_speed_template: + fan_speed_template.hass = hass + + set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) + fan_speed_list = config.get(CONF_FAN_SPEED_LIST) + + send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) + + async_add_devices([ + MqttVacuum( + name, supported_features, qos, retain, command_topic, + payload_turn_on, payload_turn_off, payload_return_to_base, + payload_stop, payload_clean_spot, payload_locate, + payload_start_pause, battery_level_topic, battery_level_template, + charging_topic, charging_template, cleaning_topic, + cleaning_template, docked_topic, docked_template, fan_speed_topic, + fan_speed_template, set_fan_speed_topic, fan_speed_list, + send_command_topic + ), + ]) + + +class MqttVacuum(VacuumDevice): + """Representation of a MQTT-controlled vacuum.""" + + # pylint: disable=no-self-use + def __init__( + self, name, supported_features, qos, retain, command_topic, + payload_turn_on, payload_turn_off, payload_return_to_base, + payload_stop, payload_clean_spot, payload_locate, + payload_start_pause, battery_level_topic, battery_level_template, + charging_topic, charging_template, cleaning_topic, + cleaning_template, docked_topic, docked_template, fan_speed_topic, + fan_speed_template, set_fan_speed_topic, fan_speed_list, + send_command_topic): + """Initialize the vacuum.""" + self._name = name + self._supported_features = supported_features + self._qos = qos + self._retain = retain + + self._command_topic = command_topic + self._payload_turn_on = payload_turn_on + self._payload_turn_off = payload_turn_off + self._payload_return_to_base = payload_return_to_base + self._payload_stop = payload_stop + self._payload_clean_spot = payload_clean_spot + self._payload_locate = payload_locate + self._payload_start_pause = payload_start_pause + + self._battery_level_topic = battery_level_topic + self._battery_level_template = battery_level_template + + self._charging_topic = charging_topic + self._charging_template = charging_template + + self._cleaning_topic = cleaning_topic + self._cleaning_template = cleaning_template + + self._docked_topic = docked_topic + self._docked_template = docked_template + + self._fan_speed_topic = fan_speed_topic + self._fan_speed_template = fan_speed_template + + self._set_fan_speed_topic = set_fan_speed_topic + self._fan_speed_list = fan_speed_list + self._send_command_topic = send_command_topic + + self._cleaning = False + self._charging = False + self._docked = False + self._status = 'Unknown' + self._battery_level = 0 + self._fan_speed = 'unknown' + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe MQTT events. + + This method is a coroutine. + """ + @callback + def message_received(topic, payload, qos): + """Handle new MQTT message.""" + if topic == self._battery_level_topic and \ + self._battery_level_template: + battery_level = self._battery_level_template\ + .async_render_with_possible_json_value( + payload, + error_value=None) + if battery_level is not None: + self._battery_level = int(battery_level) + + if topic == self._charging_topic and self._charging_template: + charging = self._charging_template\ + .async_render_with_possible_json_value( + payload, + error_value=None) + if charging is not None: + self._charging = str(charging).lower() in BOOL_TRUE_STRINGS + + if topic == self._cleaning_topic and self._cleaning_template: + cleaning = self._cleaning_template \ + .async_render_with_possible_json_value( + payload, + error_value=None) + if cleaning is not None: + self._cleaning = str(cleaning).lower() in BOOL_TRUE_STRINGS + + if topic == self._docked_topic and self._docked_template: + docked = self._docked_template \ + .async_render_with_possible_json_value( + payload, + error_value=None) + if docked is not None: + self._docked = str(docked).lower() in BOOL_TRUE_STRINGS + + if self._docked: + if self._charging: + self._status = "Docked & Charging" + else: + self._status = "Docked" + elif self._cleaning: + self._status = "Cleaning" + else: + self._status = "Stopped" + + if topic == self._fan_speed_topic and self._fan_speed_template: + fan_speed = self._fan_speed_template\ + .async_render_with_possible_json_value( + payload, + error_value=None) + if fan_speed is not None: + self._fan_speed = fan_speed + + self.async_schedule_update_ha_state() + + topics_set = [topic for topic in (self._battery_level_topic, + self._charging_topic, + self._cleaning_topic, + self._docked_topic, + self._fan_speed_topic) if topic] + for topic in topics_set: + yield from self.hass.components.mqtt.async_subscribe( + topic, message_received, self._qos) + + @property + def name(self): + """Return the name of the vacuum.""" + return self._name + + @property + def icon(self): + """Return the icon for the vacuum.""" + return DEFAULT_ICON + + @property + def should_poll(self): + """No polling needed for an MQTT vacuum.""" + return False + + @property + def is_on(self): + """Return true if vacuum is on.""" + return self._cleaning + + @property + def status(self): + """Return a status string for the vacuum.""" + if self.supported_features & SUPPORT_STATUS == 0: + return + + return self._status + + @property + def fan_speed(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + return self._fan_speed + + @property + def fan_speed_list(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return [] + return self._fan_speed_list + + @property + def battery_level(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return max(0, min(100, self._battery_level)) + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return icon_for_battery_level( + battery_level=self.battery_level, charging=self._charging) + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the vacuum on.""" + if self.supported_features & SUPPORT_TURN_ON == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_turn_on, self._qos, self._retain) + self._status = 'Cleaning' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the vacuum off.""" + if self.supported_features & SUPPORT_TURN_OFF == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_turn_off, self._qos, self._retain) + self._status = 'Turning Off' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_stop(self, **kwargs): + """Stop the vacuum.""" + if self.supported_features & SUPPORT_STOP == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, self._payload_stop, + self._qos, self._retain) + self._status = 'Stopping the current task' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + if self.supported_features & SUPPORT_CLEAN_SPOT == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_clean_spot, self._qos, self._retain) + self._status = "Cleaning spot" + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_locate(self, **kwargs): + """Locate the vacuum (usually by playing a song).""" + if self.supported_features & SUPPORT_LOCATE == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_locate, self._qos, self._retain) + self._status = "Hi, I'm over here!" + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + if self.supported_features & SUPPORT_PAUSE == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_start_pause, self._qos, self._retain) + self._status = 'Pausing/Resuming cleaning...' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_return_to_base(self, **kwargs): + """Tell the vacuum to return to its dock.""" + if self.supported_features & SUPPORT_RETURN_HOME == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_return_to_base, self._qos, + self._retain) + self._status = 'Returning home...' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + if not self._fan_speed_list or fan_speed not in self._fan_speed_list: + return + + mqtt.async_publish( + self.hass, self._set_fan_speed_topic, fan_speed, self._qos, + self._retain) + self._status = "Setting fan to {}...".format(fan_speed) + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + if self.supported_features & SUPPORT_SEND_COMMAND == 0: + return + + mqtt.async_publish( + self.hass, self._send_command_topic, command, self._qos, + self._retain) + self._status = "Sending command {}...".format(command) + self.async_schedule_update_ha_state() diff --git a/tests/components/vacuum/test_mqtt.py b/tests/components/vacuum/test_mqtt.py new file mode 100644 index 00000000000..f4c63d63708 --- /dev/null +++ b/tests/components/vacuum/test_mqtt.py @@ -0,0 +1,199 @@ +"""The tests for the Demo vacuum platform.""" +import unittest + +from homeassistant.components import vacuum +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON, ATTR_STATUS, + ATTR_FAN_SPEED, mqtt) +from homeassistant.components.mqtt import CONF_COMMAND_TOPIC +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, CONF_NAME +from homeassistant.setup import setup_component +from tests.common import ( + fire_mqtt_message, get_test_home_assistant, mock_mqtt_component) + + +class TestVacuumMQTT(unittest.TestCase): + """MQTT vacuum component test class.""" + + def setUp(self): # pylint: disable=invalid-name + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + self.default_config = { + CONF_PLATFORM: 'mqtt', + CONF_NAME: 'mqtttest', + CONF_COMMAND_TOPIC: 'vacuum/command', + mqtt.CONF_SEND_COMMAND_TOPIC: 'vacuum/send_command', + mqtt.CONF_BATTERY_LEVEL_TOPIC: 'vacuum/state', + mqtt.CONF_BATTERY_LEVEL_TEMPLATE: + '{{ value_json.battery_level }}', + mqtt.CONF_CHARGING_TOPIC: 'vacuum/state', + mqtt.CONF_CHARGING_TEMPLATE: '{{ value_json.charging }}', + mqtt.CONF_CLEANING_TOPIC: 'vacuum/state', + mqtt.CONF_CLEANING_TEMPLATE: '{{ value_json.cleaning }}', + mqtt.CONF_DOCKED_TOPIC: 'vacuum/state', + mqtt.CONF_DOCKED_TEMPLATE: '{{ value_json.docked }}', + mqtt.CONF_STATE_TOPIC: 'vacuum/state', + mqtt.CONF_STATE_TEMPLATE: '{{ value_json.state }}', + mqtt.CONF_FAN_SPEED_TOPIC: 'vacuum/state', + mqtt.CONF_FAN_SPEED_TEMPLATE: '{{ value_json.fan_speed }}', + mqtt.CONF_SET_FAN_SPEED_TOPIC: 'vacuum/set_fan_speed', + mqtt.CONF_FAN_SPEED_LIST: ['min', 'medium', 'high', 'max'], + } + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_default_supported_features(self): + """Test that the correct supported features.""" + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + entity = self.hass.states.get('vacuum.mqtttest') + entity_features = \ + entity.attributes.get(mqtt.CONF_SUPPORTED_FEATURES, 0) + self.assertListEqual(sorted(mqtt.services_to_strings(entity_features)), + sorted(['turn_on', 'turn_off', 'stop', + 'return_home', 'battery', 'status', + 'clean_spot'])) + + def test_all_commands(self): + """Test simple commands to the vacuum.""" + self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ + mqtt.services_to_strings(mqtt.ALL_SERVICES) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + vacuum.turn_on(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'turn_on', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.turn_off(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'turn_off', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.stop(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'stop', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.clean_spot(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'clean_spot', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.locate(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'locate', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.start_pause(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'start_pause', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.return_to_base(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'return_to_base', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.set_fan_speed(self.hass, 'high', 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual( + ('vacuum/set_fan_speed', 'high', 0, False), + self.mock_publish.mock_calls[-2][1] + ) + + vacuum.send_command(self.hass, '44 FE 93', entity_id='vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual( + ('vacuum/send_command', '44 FE 93', 0, False), + self.mock_publish.mock_calls[-2][1] + ) + + def test_status(self): + """Test status updates from the vacuum.""" + self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ + mqtt.services_to_strings(mqtt.ALL_SERVICES) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + message = """{ + "battery_level": 54, + "cleaning": true, + "docked": false, + "charging": false, + "fan_speed": "max" + }""" + fire_mqtt_message(self.hass, 'vacuum/state', message) + self.hass.block_till_done() + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(STATE_ON, state.state) + self.assertEqual( + 'mdi:battery-50', + state.attributes.get(ATTR_BATTERY_ICON) + ) + self.assertEqual(54, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual('max', state.attributes.get(ATTR_FAN_SPEED)) + + message = """{ + "battery_level": 61, + "docked": true, + "cleaning": false, + "charging": true, + "fan_speed": "min" + }""" + + fire_mqtt_message(self.hass, 'vacuum/state', message) + self.hass.block_till_done() + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual( + 'mdi:battery-charging-60', + state.attributes.get(ATTR_BATTERY_ICON) + ) + self.assertEqual(61, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual('min', state.attributes.get(ATTR_FAN_SPEED)) + + def test_battery_template(self): + """Test that you can use non-default templates for battery_level.""" + self.default_config.update({ + mqtt.CONF_SUPPORTED_FEATURES: + mqtt.services_to_strings(mqtt.ALL_SERVICES), + mqtt.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", + mqtt.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}" + }) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + fire_mqtt_message(self.hass, 'retroroomba/battery_level', '54') + self.hass.block_till_done() + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(54, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual(state.attributes.get(ATTR_BATTERY_ICON), + 'mdi:battery-50') + + def test_status_invalid_json(self): + """Test to make sure nothing breaks if the vacuum sends bad JSON.""" + self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ + mqtt.services_to_strings(mqtt.ALL_SERVICES) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + fire_mqtt_message(self.hass, 'vacuum/state', '{"asdfasas false}') + self.hass.block_till_done() + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual("Stopped", state.attributes.get(ATTR_STATUS)) From 5de39fd1187c8254d4e5068d89169a8a4060633c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Sep 2017 18:50:22 +0200 Subject: [PATCH 191/277] Optimaze vacuum mqtt platform (#9439) * Optimaze vacuum mqtt platform * fix lint * Update mqtt.py --- homeassistant/components/vacuum/mqtt.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py index 853c50369a2..67ee6fb15c7 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/vacuum/mqtt.py @@ -64,8 +64,6 @@ DEFAULT_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STOP |\ ALL_SERVICES = DEFAULT_SERVICES | SUPPORT_PAUSE | SUPPORT_LOCATE |\ SUPPORT_FAN_SPEED | SUPPORT_SEND_COMMAND -BOOL_TRUE_STRINGS = {'true', '1', 'yes', 'on'} - CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES CONF_PAYLOAD_TURN_ON = 'payload_turn_on' CONF_PAYLOAD_TURN_OFF = 'payload_turn_off' @@ -281,7 +279,7 @@ class MqttVacuum(VacuumDevice): payload, error_value=None) if charging is not None: - self._charging = str(charging).lower() in BOOL_TRUE_STRINGS + self._charging = cv.boolean(charging) if topic == self._cleaning_topic and self._cleaning_template: cleaning = self._cleaning_template \ @@ -289,7 +287,7 @@ class MqttVacuum(VacuumDevice): payload, error_value=None) if cleaning is not None: - self._cleaning = str(cleaning).lower() in BOOL_TRUE_STRINGS + self._cleaning = cv.boolean(cleaning) if topic == self._docked_topic and self._docked_template: docked = self._docked_template \ @@ -297,7 +295,7 @@ class MqttVacuum(VacuumDevice): payload, error_value=None) if docked is not None: - self._docked = str(docked).lower() in BOOL_TRUE_STRINGS + self._docked = cv.boolean(docked) if self._docked: if self._charging: @@ -319,12 +317,12 @@ class MqttVacuum(VacuumDevice): self.async_schedule_update_ha_state() - topics_set = [topic for topic in (self._battery_level_topic, - self._charging_topic, - self._cleaning_topic, - self._docked_topic, - self._fan_speed_topic) if topic] - for topic in topics_set: + topics_list = [topic for topic in (self._battery_level_topic, + self._charging_topic, + self._cleaning_topic, + self._docked_topic, + self._fan_speed_topic) if topic] + for topic in set(topics_list): yield from self.hass.components.mqtt.async_subscribe( topic, message_received, self._qos) From 26c98512c8bbddb7092c564f58f385fa6b9e59f3 Mon Sep 17 00:00:00 2001 From: Ted Drain Date: Fri, 15 Sep 2017 22:25:32 -0700 Subject: [PATCH 192/277] Polymer access to log file broken when using new log file command line (#9437) * Changed api.py to use new log file name * Only serve log file if logs are active * Changed log file location to be in hass.data --- homeassistant/bootstrap.py | 6 ++++++ homeassistant/components/api.py | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 1fa113ab597..3ff4d99fb98 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -27,6 +27,10 @@ from homeassistant.helpers.signal import async_register_signal_handling _LOGGER = logging.getLogger(__name__) ERROR_LOG_FILENAME = 'home-assistant.log' + +# hass.data key for logging information. +DATA_LOGGING = 'logging' + FIRST_INIT_COMPONENT = set(( 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction', 'frontend', 'history')) @@ -281,6 +285,8 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, logger.addHandler(async_handler) logger.setLevel(logging.INFO) + # Save the log file location for access by other components. + hass.data[DATA_LOGGING] = err_log_path else: _LOGGER.error( "Unable to setup error log %s (access denied)", err_log_path) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index c22683970bf..3b905ab0420 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -13,7 +13,7 @@ import async_timeout import homeassistant.core as ha import homeassistant.remote as rem -from homeassistant.bootstrap import ERROR_LOG_FILENAME +from homeassistant.bootstrap import DATA_LOGGING from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, @@ -51,8 +51,9 @@ def setup(hass, config): hass.http.register_view(APIComponentsView) hass.http.register_view(APITemplateView) - hass.http.register_static_path( - URL_API_ERROR_LOG, hass.config.path(ERROR_LOG_FILENAME), False) + log_path = hass.data.get(DATA_LOGGING, None) + if log_path: + hass.http.register_static_path(URL_API_ERROR_LOG, log_path, False) return True From a7bce5f9e634daa4c9d02f90d4bc2b02f45dc3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Sat, 16 Sep 2017 07:55:53 +0200 Subject: [PATCH 193/277] Allow empty hostnames when detecting devices with the aruba device_tracker. (#9440) --- homeassistant/components/device_tracker/aruba.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index cef5eabd901..79d8806fe22 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -19,9 +19,9 @@ _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pexpect==4.0.1'] _DEVICES_REGEX = re.compile( - r'(?P([^\s]+))\s+' + + r'(?P([^\s]+)?)\s+' + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+') + r'(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))\s+') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, From 04bed51277c20f51d623496b4bdd72eb8b59b841 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 16 Sep 2017 00:05:58 -0600 Subject: [PATCH 194/277] mqtt_statestream: Update to append 'state' to topic for future use with mqtt discovery (#9446) --- homeassistant/components/mqtt_statestream.py | 2 +- tests/components/test_mqtt_statestream.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py index 76154e4ab58..2b68394b160 100644 --- a/homeassistant/components/mqtt_statestream.py +++ b/homeassistant/components/mqtt_statestream.py @@ -38,7 +38,7 @@ def async_setup(hass, config): return payload = new_state.state - topic = base_topic + entity_id.replace('.', '/') + topic = base_topic + entity_id.replace('.', '/') + '/state' hass.components.mqtt.async_publish(topic, payload, 1, True) async_track_state_change(hass, MATCH_ALL, _state_publisher) diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py index 73e2dbd1ac4..cbd7838effe 100644 --- a/tests/components/test_mqtt_statestream.py +++ b/tests/components/test_mqtt_statestream.py @@ -59,7 +59,7 @@ class TestMqttStateStream(object): mock_state_change_event(self.hass, State(e_id, 'on')) self.hass.block_till_done() - # Make sure 'on' was published to pub/fake/entity - mock_pub.assert_called_with(self.hass, 'pub/fake/entity', 'on', 1, - True) + # Make sure 'on' was published to pub/fake/entity/state + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', + 'on', 1, True) assert mock_pub.called From 7b0628421d96b73e1aef7d2c4c777b4aa545d9ca Mon Sep 17 00:00:00 2001 From: Kyle Hendricks Date: Sat, 16 Sep 2017 02:12:06 -0400 Subject: [PATCH 195/277] Fix for DTE Energy Bridge returning the wrong units from time to time (#9246) The DTE Energy Bridge seems to return the current energy usage randomly in either W or kW. The only way to tell the difference is if there is a decimal or not in the result. Also added some tests. --- .../components/sensor/dte_energy_bridge.py | 7 +- .../sensor/test_dte_energy_bridge.py | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/components/sensor/test_dte_energy_bridge.py diff --git a/homeassistant/components/sensor/dte_energy_bridge.py b/homeassistant/components/sensor/dte_energy_bridge.py index ee80c4f76fe..00da6c2ce51 100644 --- a/homeassistant/components/sensor/dte_energy_bridge.py +++ b/homeassistant/components/sensor/dte_energy_bridge.py @@ -91,4 +91,9 @@ class DteEnergyBridgeSensor(Entity): response.text, self._name) return - self._state = float(response_split[0]) + val = float(response_split[0]) + + # A workaround for a bug in the DTE energy bridge. + # The returned value can randomly be in W or kW. Checking for a + # a decimal seems to be a reliable way to determine the units. + self._state = val if '.' in response_split[0] else val / 1000 diff --git a/tests/components/sensor/test_dte_energy_bridge.py b/tests/components/sensor/test_dte_energy_bridge.py new file mode 100644 index 00000000000..2341c3f8350 --- /dev/null +++ b/tests/components/sensor/test_dte_energy_bridge.py @@ -0,0 +1,68 @@ +"""The tests for the DTE Energy Bridge.""" + +import unittest + +import requests_mock + +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant + +DTE_ENERGY_BRIDGE_CONFIG = { + 'platform': 'dte_energy_bridge', + 'ip': '192.168.1.1', +} + + +class TestDteEnergyBridgeSetup(unittest.TestCase): + """Test the DTE Energy Bridge platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_with_config(self): + """Test the platform setup with configuration.""" + self.assertTrue( + setup_component(self.hass, 'sensor', + {'dte_energy_bridge': DTE_ENERGY_BRIDGE_CONFIG})) + + @requests_mock.Mocker() + def test_setup_correct_reading(self, mock_req): + """Test DTE Energy bridge returns a correct value.""" + mock_req.get("http://{}/instantaneousdemand" + .format(DTE_ENERGY_BRIDGE_CONFIG['ip']), + text='.411 kW') + assert setup_component(self.hass, 'sensor', { + 'sensor': DTE_ENERGY_BRIDGE_CONFIG}) + self.assertEqual('0.411', + self.hass.states + .get('sensor.current_energy_usage').state) + + @requests_mock.Mocker() + def test_setup_incorrect_units_reading(self, mock_req): + """Test DTE Energy bridge handles a value with incorrect units.""" + mock_req.get("http://{}/instantaneousdemand" + .format(DTE_ENERGY_BRIDGE_CONFIG['ip']), + text='411 kW') + assert setup_component(self.hass, 'sensor', { + 'sensor': DTE_ENERGY_BRIDGE_CONFIG}) + self.assertEqual('0.411', + self.hass.states + .get('sensor.current_energy_usage').state) + + @requests_mock.Mocker() + def test_setup_bad_format_reading(self, mock_req): + """Test DTE Energy bridge handles an invalid value.""" + mock_req.get("http://{}/instantaneousdemand" + .format(DTE_ENERGY_BRIDGE_CONFIG['ip']), + text='411') + assert setup_component(self.hass, 'sensor', { + 'sensor': DTE_ENERGY_BRIDGE_CONFIG}) + self.assertEqual('unknown', + self.hass.states + .get('sensor.current_energy_usage').state) From 515982a6926b50355e606bef9999f9af1448c1c4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 16 Sep 2017 08:13:30 +0200 Subject: [PATCH 196/277] Refactor Swiss Public Transport sensor (#9129) * Refactor Swiss Public Transport sensor * Minor change --- .../sensor/swiss_public_transport.py | 141 ++++++++---------- requirements_all.txt | 3 + 2 files changed, 63 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index 0febd8c95bc..973eac0bdde 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -4,10 +4,10 @@ Support for transport.opendata.ch. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.swiss_public_transport/ """ +import asyncio import logging from datetime import timedelta -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -15,15 +15,21 @@ import homeassistant.util.dt as dt_util from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['python_opendata_transport==0.0.2'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'http://transport.opendata.ch/v1/' ATTR_DEPARTURE_TIME1 = 'next_departure' ATTR_DEPARTURE_TIME2 = 'next_on_departure' +ATTR_DURATION = 'duration' +ATTR_PLATFORM = 'platform' ATTR_REMAINING_TIME = 'remaining_time' ATTR_START = 'start' ATTR_TARGET = 'destination' +ATTR_TRAIN_NUMBER = 'train_number' +ATTR_TRANSFERS = 'transfers' CONF_ATTRIBUTION = "Data provided by transport.opendata.ch" CONF_DESTINATION = 'to' @@ -33,9 +39,7 @@ DEFAULT_NAME = 'Next Departure' ICON = 'mdi:bus' -SCAN_INTERVAL = timedelta(minutes=1) - -TIME_STR_FORMAT = "%H:%M" +SCAN_INTERVAL = timedelta(seconds=90) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DESTINATION): cv.string, @@ -44,39 +48,39 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Swiss public transport sensor.""" name = config.get(CONF_NAME) - # journal contains [0] Station ID start, [1] Station ID destination - # [2] Station name start, and [3] Station name destination - journey = [config.get(CONF_START), config.get(CONF_DESTINATION)] - try: - for location in [config.get(CONF_START), config.get(CONF_DESTINATION)]: - # transport.opendata.ch doesn't play nice with requests.Session - result = requests.get( - '{}locations?query={}'.format(_RESOURCE, location), timeout=10) - journey.append(result.json()['stations'][0]['name']) - except KeyError: - _LOGGER.exception( - "Unable to determine stations. " - "Check your settings and/or the availability of opendata.ch") + start = config.get(CONF_START) + destination = config.get(CONF_DESTINATION) + + connection = SwissPublicTransportSensor(hass, start, destination, name) + yield from connection.async_update() + + if connection.state is None: + _LOGGER.error( + "Check at http://transport.opendata.ch/examples/stationboard.html " + "if your station names are valid") return False - data = PublicTransportData(journey) - add_devices([SwissPublicTransportSensor(data, journey, name)], True) + async_add_devices([connection]) class SwissPublicTransportSensor(Entity): """Implementation of an Swiss public transport sensor.""" - def __init__(self, data, journey, name): + def __init__(self, hass, start, destination, name): """Initialize the sensor.""" - self.data = data + from opendata_transport import OpendataTransport + + self.hass = hass self._name = name - self._state = None - self._times = None - self._from = journey[2] - self._to = journey[3] + self._from = start + self._to = destination + self._websession = async_get_clientsession(self.hass) + self._opendata = OpendataTransport( + self._from, self._to, self.hass.loop, self._websession) @property def name(self): @@ -86,70 +90,45 @@ class SwissPublicTransportSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return self._state + return self._opendata.connections[0]['departure'] \ + if self._opendata is not None else None @property def device_state_attributes(self): """Return the state attributes.""" - if self._times is not None: - return { - ATTR_DEPARTURE_TIME1: self._times[0], - ATTR_DEPARTURE_TIME2: self._times[1], - ATTR_START: self._from, - ATTR_TARGET: self._to, - ATTR_REMAINING_TIME: '{}'.format( - ':'.join(str(self._times[2]).split(':')[:2])), - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - } + if self._opendata is None: + return + + remaining_time = dt_util.parse_datetime( + self._opendata.connections[0]['departure']) -\ + dt_util.as_local(dt_util.utcnow()) + + attr = { + ATTR_TRAIN_NUMBER: self._opendata.connections[0]['number'], + ATTR_PLATFORM: self._opendata.connections[0]['platform'], + ATTR_TRANSFERS: self._opendata.connections[0]['transfers'], + ATTR_DURATION: self._opendata.connections[0]['duration'], + ATTR_DEPARTURE_TIME1: self._opendata.connections[1]['departure'], + ATTR_DEPARTURE_TIME2: self._opendata.connections[2]['departure'], + ATTR_START: self._opendata.from_name, + ATTR_TARGET: self._opendata.to_name, + ATTR_REMAINING_TIME: '{}'.format(remaining_time), + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + return attr @property def icon(self): """Icon to use in the frontend, if any.""" return ICON - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data from opendata.ch and update the states.""" - self.data.update() - self._times = self.data.times - try: - self._state = self._times[0] - except TypeError: - pass - - -class PublicTransportData(object): - """The Class for handling the data retrieval.""" - - def __init__(self, journey): - """Initialize the data object.""" - self.start = journey[0] - self.destination = journey[1] - self.times = {} - - def update(self): - """Get the latest data from opendata.ch.""" - response = requests.get( - _RESOURCE + - 'connections?' + - 'from=' + self.start + '&' + - 'to=' + self.destination + '&' + - 'fields[]=connections/from/departureTimestamp/&' + - 'fields[]=connections/', - timeout=10) - connections = response.json()['connections'][1:3] + from opendata_transport.exceptions import OpendataTransportError try: - self.times = [ - dt_util.as_local( - dt_util.utc_from_timestamp( - item['from']['departureTimestamp'])).strftime( - TIME_STR_FORMAT) - for item in connections - ] - self.times.append( - dt_util.as_local( - dt_util.utc_from_timestamp( - connections[0]['from']['departureTimestamp'])) - - dt_util.as_local(dt_util.utcnow())) - except KeyError: - self.times = ['n/a'] + yield from self._opendata.async_get_data() + except OpendataTransportError: + _LOGGER.error("Unable to retrieve data from transport.opendata.ch") + self._opendata = None diff --git a/requirements_all.txt b/requirements_all.txt index d4177d5570c..d7907bbcf02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -799,6 +799,9 @@ python-vlc==1.1.2 # homeassistant.components.wink python-wink==1.5.1 +# homeassistant.components.sensor.swiss_public_transport +python_opendata_transport==0.0.2 + # homeassistant.components.zwave python_openzwave==0.4.0.31 From 78bb0da5a042eaeb52a0650c993661e56222ddb2 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Sat, 16 Sep 2017 15:29:24 +0700 Subject: [PATCH 197/277] Added Zyxel Keenetic NDMS2 based routers support for device tracking (#9315) * Added Zyxel Keenetic NDMS2 based routers support for device tracking * Review feedback * Review feedback+ * Review feedback: removed unneeded code --- .coveragerc | 1 + .../device_tracker/keenetic_ndms2.py | 121 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 homeassistant/components/device_tracker/keenetic_ndms2.py diff --git a/.coveragerc b/.coveragerc index 0ed94e62199..4f621763bec 100644 --- a/.coveragerc +++ b/.coveragerc @@ -284,6 +284,7 @@ omit = homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py + homeassistant/components/device_tracker/keenetic_ndms2.py homeassistant/components/device_tracker/linksys_ap.py homeassistant/components/device_tracker/linksys_smart.py homeassistant/components/device_tracker/luci.py diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py new file mode 100644 index 00000000000..5a7db36e479 --- /dev/null +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -0,0 +1,121 @@ +""" +Support for Zyxel Keenetic NDMS2 based routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.keenetic_ndms2/ +""" +import logging +from collections import namedtuple + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME +) + +_LOGGER = logging.getLogger(__name__) + +# Interface name to track devices for. Most likely one will not need to +# change it from default 'Home'. This is needed not to track Guest WI-FI- +# clients and router itself +CONF_INTERFACE = 'interface' + +DEFAULT_INTERFACE = 'Home' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name']) + + +class KeeneticNDMS2DeviceScanner(DeviceScanner): + """This class scans for devices using keenetic NDMS2 web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + + self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] + self._interface = config[CONF_INTERFACE] + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + + self.success_init = self._update_info() + _LOGGER.info("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, mac): + """Return the name of the given device or None if we don't know.""" + filter_named = [device.name for device in self.last_results + if device.mac == mac] + + if filter_named: + return filter_named[0] + return None + + def _update_info(self): + """Get ARP from keenetic router.""" + _LOGGER.info("Fetching...") + + last_results = [] + + # doing a request + try: + from requests.auth import HTTPDigestAuth + res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth( + self._username, self._password + )) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.error("Failed to parse response from router") + return False + + # parsing response + for info in result: + if info.get('interface') != self._interface: + continue + mac = info.get('mac') + name = info.get('name') + # No address = no item :) + if mac is None: + continue + + last_results.append(Device(mac.upper(), name)) + + self.last_results = last_results + + _LOGGER.info("Request successful") + return True From f3fc571cd53eab4b83683e9225b6f5542582f895 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 16 Sep 2017 02:32:24 -0600 Subject: [PATCH 198/277] Add city/state/country options and fix bugs for airvisual (#9436) * Added city/state/country options and fixed several bugs * Added some slightly better error logging * Making collaborator-requested changes --- homeassistant/components/sensor/airvisual.py | 107 +++++++++++-------- 1 file changed, 63 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 7b077aa38ee..5e88dfa8bc9 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -13,9 +13,9 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_STATE, CONF_API_KEY, - CONF_LATITUDE, CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS) +from homeassistant.const import (ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, + CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + CONF_STATE) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -26,8 +26,11 @@ ATTR_CITY = 'city' ATTR_COUNTRY = 'country' ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' ATTR_POLLUTANT_UNIT = 'pollutant_unit' +ATTR_REGION = 'region' ATTR_TIMESTAMP = 'timestamp' +CONF_CITY = 'city' +CONF_COUNTRY = 'country' CONF_RADIUS = 'radius' MASS_PARTS_PER_MILLION = 'ppm' @@ -106,6 +109,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ cv.longitude, vol.Optional(CONF_RADIUS, default=1000): cv.positive_int, + vol.Optional(CONF_CITY): + cv.string, + vol.Optional(CONF_STATE): + cv.string, + vol.Optional(CONF_COUNTRY): + cv.string }) @@ -114,22 +123,28 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Configure the platform and add the sensors.""" import pyairvisual as pav + _LOGGER.debug('Received configuration: %s', config) + api_key = config.get(CONF_API_KEY) - _LOGGER.debug('AirVisual API Key: %s', api_key) - monitored_locales = config.get(CONF_MONITORED_CONDITIONS) - _LOGGER.debug('Monitored Conditions: %s', monitored_locales) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - _LOGGER.debug('AirVisual Latitude: %s', latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - _LOGGER.debug('AirVisual Longitude: %s', longitude) - radius = config.get(CONF_RADIUS) - _LOGGER.debug('AirVisual Radius: %s', radius) + city = config.get(CONF_CITY) + state = config.get(CONF_STATE) + country = config.get(CONF_COUNTRY) - data = AirVisualData(pav.Client(api_key), latitude, longitude, radius) + if city and state and country: + _LOGGER.debug('Constructing sensors based on city, state, and country') + data = AirVisualData( + pav.Client(api_key), city=city, state=state, country=country) + else: + _LOGGER.debug('Constructing sensors based on latitude and longitude') + data = AirVisualData( + pav.Client(api_key), + latitude=latitude, + longitude=longitude, + radius=radius) sensors = [] for locale in monitored_locales: @@ -161,14 +176,13 @@ class AirVisualBaseSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if self._data: - return { - ATTR_ATTRIBUTION: 'AirVisual©', - ATTR_CITY: self._data.city, - ATTR_COUNTRY: self._data.country, - ATTR_STATE: self._data.state, - ATTR_TIMESTAMP: self._data.pollution_info.get('ts') - } + return { + ATTR_ATTRIBUTION: 'AirVisual©', + ATTR_CITY: self._data.city, + ATTR_COUNTRY: self._data.country, + ATTR_REGION: self._data.state, + ATTR_TIMESTAMP: self._data.pollution_info.get('ts') + } @property def icon(self): @@ -188,7 +202,7 @@ class AirVisualBaseSensor(Entity): @asyncio.coroutine def async_update(self): """Update the status of the sensor.""" - _LOGGER.debug('updating sensor: %s', self._name) + _LOGGER.debug('Updating sensor: %s', self._name) self._data.update() @@ -200,13 +214,14 @@ class AirPollutionLevelSensor(AirVisualBaseSensor): """Update the status of the sensor.""" yield from super().async_update() aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) - try: [level] = [ i for i in POLLUTANT_LEVEL_MAPPING if i['minimum'] <= aqi <= i['maximum'] ] self._state = level.get('label') + except TypeError: + self._state = None except ValueError: self._state = None @@ -217,12 +232,13 @@ class AirQualityIndexSensor(AirVisualBaseSensor): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return '' + return 'PSI' @asyncio.coroutine def async_update(self): """Update the status of the sensor.""" yield from super().async_update() + self._state = self._data.pollution_info.get( 'aqi{0}'.format(self._locale)) @@ -239,11 +255,10 @@ class MainPollutantSensor(AirVisualBaseSensor): @property def device_state_attributes(self): """Return the state attributes.""" - if self._data: - return merge_two_dicts(super().device_state_attributes, { - ATTR_POLLUTANT_SYMBOL: self._symbol, - ATTR_POLLUTANT_UNIT: self._unit - }) + return merge_two_dicts(super().device_state_attributes, { + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) @asyncio.coroutine def async_update(self): @@ -259,16 +274,18 @@ class MainPollutantSensor(AirVisualBaseSensor): class AirVisualData(object): """Define an object to hold sensor data.""" - def __init__(self, client, latitude, longitude, radius): + def __init__(self, client, **kwargs): """Initialize.""" - self.city = None self._client = client - self.country = None - self.latitude = latitude - self.longitude = longitude self.pollution_info = None - self.radius = radius - self.state = None + + self.city = kwargs.get(CONF_CITY) + self.state = kwargs.get(CONF_STATE) + self.country = kwargs.get(CONF_COUNTRY) + + self.latitude = kwargs.get(CONF_LATITUDE) + self.longitude = kwargs.get(CONF_LONGITUDE) + self.radius = kwargs.get(CONF_RADIUS) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -276,14 +293,16 @@ class AirVisualData(object): import pyairvisual.exceptions as exceptions try: - resp = self._client.nearest_city(self.latitude, self.longitude, - self.radius).get('data') + if self.city and self.state and self.country: + resp = self._client.city(self.city, self.state, + self.country).get('data') + else: + resp = self._client.nearest_city(self.latitude, self.longitude, + self.radius).get('data') _LOGGER.debug('New data retrieved: %s', resp) - - self.city = resp.get('city') - self.state = resp.get('state') - self.country = resp.get('country') - self.pollution_info = resp.get('current').get('pollution') + self.pollution_info = resp.get('current', {}).get('pollution', {}) except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to update sensor data') + _LOGGER('Unable to retrieve data from the API') + _LOGGER.error("There is likely no data on this location") _LOGGER.debug(exc_info) + self.pollution_info = {} From 73a15ddd6434762b85394831ddd122145c51b31e Mon Sep 17 00:00:00 2001 From: Adam Stone Date: Sat, 16 Sep 2017 11:17:27 -0400 Subject: [PATCH 199/277] Fix emulated hue warning message (#9452) --- homeassistant/components/emulated_hue/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index ae0a26aaea4..ca056398d2b 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -129,7 +129,7 @@ class Config(object): if self.type == TYPE_ALEXA: _LOGGER.warning("Alexa type is deprecated and will be removed in a" - "future version") + " future version") # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) From c2bbc2f74edfbee1b5e62dd5835ca708ab5d63fa Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 16 Sep 2017 21:35:28 +0200 Subject: [PATCH 200/277] Alexa smart home native support (#9443) * Init commit for alexa component * more struct component * Add mapping for device/component to alexa action * finish discovery * First version with support on/off/percent * fix spell * First init tests * fix tests & lint * add tests & fix bugs * optimaze tests * more tests * Finish tests * fix lint * Address paulus comments * fix lint * Fix lint p2 * Optimaze & paulus comment --- homeassistant/components/alexa/smart_home.py | 185 +++++++++++++++++++ tests/components/alexa/test_smart_home.py | 182 ++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 homeassistant/components/alexa/smart_home.py create mode 100644 tests/components/alexa/test_smart_home.py diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py new file mode 100644 index 00000000000..aa4b1cbec70 --- /dev/null +++ b/homeassistant/components/alexa/smart_home.py @@ -0,0 +1,185 @@ +"""Support for alexa Smart Home Skill API.""" +import asyncio +import logging +from uuid import uuid4 + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) +from homeassistant.components import switch, light + +_LOGGER = logging.getLogger(__name__) + +ATTR_HEADER = 'header' +ATTR_NAME = 'name' +ATTR_NAMESPACE = 'namespace' +ATTR_MESSAGE_ID = 'messageId' +ATTR_PAYLOAD = 'payload' +ATTR_PAYLOAD_VERSION = 'payloadVersion' + + +MAPPING_COMPONENT = { + switch.DOMAIN: ['SWITCH', ('turnOff', 'turnOn'), None], + light.DOMAIN: [ + 'LIGHT', ('turnOff', 'turnOn'), { + light.SUPPORT_BRIGHTNESS: 'setPercentage' + } + ], +} + + +def mapping_api_function(name): + """Return function pointer to api function for name. + + Async friendly. + """ + mapping = { + 'DiscoverAppliancesRequest': async_api_discovery, + 'TurnOnRequest': async_api_turn_on, + 'TurnOffRequest': async_api_turn_off, + 'SetPercentageRequest': async_api_set_percentage, + } + return mapping.get(name, None) + + +@asyncio.coroutine +def async_handle_message(hass, message): + """Handle incomming API messages.""" + assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2 + + # Do we support this API request? + funct_ref = mapping_api_function(message[ATTR_HEADER][ATTR_NAME]) + if not funct_ref: + _LOGGER.warning( + "Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME]) + return api_error(message) + + return (yield from funct_ref(hass, message)) + + +def api_message(name, namespace, payload=None): + """Create a API formated response message. + + Async friendly. + """ + payload = payload or {} + return { + ATTR_HEADER: { + ATTR_MESSAGE_ID: uuid4(), + ATTR_NAME: name, + ATTR_NAMESPACE: namespace, + ATTR_PAYLOAD_VERSION: '2', + }, + ATTR_PAYLOAD: payload, + } + + +def api_error(request, exc='DriverInternalError'): + """Create a API formated error response. + + Async friendly. + """ + return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE]) + + +@asyncio.coroutine +def async_api_discovery(hass, request): + """Create a API formated discovery response. + + Async friendly. + """ + discovered_appliances = [] + + for entity in hass.states.async_all(): + class_data = MAPPING_COMPONENT.get(entity.domain) + + if not class_data: + continue + + appliance = { + 'actions': [], + 'applianceTypes': [class_data[0]], + 'additionalApplianceDetails': {}, + 'applianceId': entity.entity_id.replace('.', '#'), + 'friendlyDescription': '', + 'friendlyName': entity.name, + 'isReachable': True, + 'manufacturerName': 'Unknown', + 'modelName': 'Unknown', + 'version': 'Unknown', + } + + # static actions + if class_data[1]: + appliance['actions'].extend(list(class_data[1])) + + # dynamic actions + if class_data[2]: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + for feature, action_name in class_data[2].items(): + if feature & supported > 0: + appliance['actions'].append(action_name) + + discovered_appliances.append(appliance) + + return api_message( + 'DiscoverAppliancesResponse', 'Alexa.ConnectedHome.Discovery', + payload={'discoveredAppliances': discovered_appliances}) + + +def extract_entity(funct): + """Decorator for extract entity object from request.""" + @asyncio.coroutine + def async_api_entity_wrapper(hass, request): + """Process a turn on request.""" + entity_id = \ + request[ATTR_PAYLOAD]['appliance']['applianceId'].replace('#', '.') + + # extract state object + entity = hass.states.get(entity_id) + if not entity: + _LOGGER.error("Can't process %s for %s", + request[ATTR_HEADER][ATTR_NAME], entity_id) + return api_error(request) + + return (yield from funct(hass, request, entity)) + + return async_api_entity_wrapper + + +@extract_entity +@asyncio.coroutine +def async_api_turn_on(hass, request, entity): + """Process a turn on request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control') + + +@extract_entity +@asyncio.coroutine +def async_api_turn_off(hass, request, entity): + """Process a turn off request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control') + + +@extract_entity +@asyncio.coroutine +def async_api_set_percentage(hass, request, entity): + """Process a set percentage request.""" + if entity.domain == light.DOMAIN: + brightness = request[ATTR_PAYLOAD]['percentageState']['value'] + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS: brightness, + }, blocking=True) + else: + return api_error(request) + + return api_message( + 'SetPercentageConfirmation', 'Alexa.ConnectedHome.Control') diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py new file mode 100644 index 00000000000..0c2b133bdfb --- /dev/null +++ b/tests/components/alexa/test_smart_home.py @@ -0,0 +1,182 @@ +"""Test for smart home alexa support.""" +import asyncio + +import pytest + +from homeassistant.components.alexa import smart_home + +from tests.common import async_mock_service + + +def test_create_api_message(): + """Create a API message.""" + msg = smart_home.api_message('testName', 'testNameSpace') + + assert msg['header']['messageId'] is not None + assert msg['header']['name'] == 'testName' + assert msg['header']['namespace'] == 'testNameSpace' + assert msg['header']['payloadVersion'] == '2' + assert msg['payload'] == {} + + +def test_mapping_api_funct(): + """Test function ref from mapping function.""" + assert smart_home.mapping_api_function('notExists') is None + assert smart_home.mapping_api_function('DiscoverAppliancesRequest') == \ + smart_home.async_api_discovery + assert smart_home.mapping_api_function('TurnOnRequest') == \ + smart_home.async_api_turn_on + assert smart_home.mapping_api_function('TurnOffRequest') == \ + smart_home.async_api_turn_off + assert smart_home.mapping_api_function('SetPercentageRequest') == \ + smart_home.async_api_set_percentage + + +@asyncio.coroutine +def test_wrong_version(hass): + """Test with wrong version.""" + msg = smart_home.api_message('testName', 'testNameSpace') + msg['header']['payloadVersion'] = '3' + + with pytest.raises(AssertionError): + yield from smart_home.async_handle_message(hass, msg) + + +@asyncio.coroutine +def test_discovery_request(hass): + """Test alexa discovery request.""" + msg = smart_home.api_message( + 'DiscoverAppliancesRequest', 'Alexa.ConnectedHome.Discovery') + + # settup test devices + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + + hass.states.async_set( + 'light.test_1', 'on', {'friendly_name': "Test light 1"}) + hass.states.async_set( + 'light.test_2', 'on', { + 'friendly_name': "Test light 2", 'supported_features': 1 + }) + + resp = yield from smart_home.async_api_discovery(hass, msg) + + assert len(resp['payload']['discoveredAppliances']) == 3 + assert resp['header']['name'] == 'DiscoverAppliancesResponse' + assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Discovery' + + for i, appliance in enumerate(resp['payload']['discoveredAppliances']): + if appliance['applianceId'] == 'switch#test': + assert appliance['applianceTypes'][0] == "SWITCH" + assert appliance['friendlyName'] == "Test switch" + assert appliance['actions'] == ['turnOff', 'turnOn'] + continue + + if appliance['applianceId'] == 'light#test_1': + assert appliance['applianceTypes'][0] == "LIGHT" + assert appliance['friendlyName'] == "Test light 1" + assert appliance['actions'] == ['turnOff', 'turnOn'] + continue + + if appliance['applianceId'] == 'light#test_2': + assert appliance['applianceTypes'][0] == "LIGHT" + assert appliance['friendlyName'] == "Test light 2" + assert appliance['actions'] == \ + ['turnOff', 'turnOn', 'setPercentage'] + continue + + raise AssertionError("Unknown appliance!") + + +@asyncio.coroutine +def test_api_entity_not_exists(hass): + """Test api turn on process without entity.""" + msg_switch = smart_home.api_message( + 'TurnOnRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': 'switch#test' + } + }) + + call_switch = async_mock_service(hass, 'switch', 'turn_on') + + resp = yield from smart_home.async_api_turn_on(hass, msg_switch) + assert len(call_switch) == 0 + assert resp['header']['name'] == 'DriverInternalError' + assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Control' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['light', 'switch']) +def test_api_turn_on(hass, domain): + """Test api turn on process.""" + msg = smart_home.api_message( + 'TurnOnRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': '{}#test'.format(domain) + } + }) + + # settup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'turn_on') + + resp = yield from smart_home.async_api_turn_on(hass, msg) + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert resp['header']['name'] == 'TurnOnConfirmation' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['light', 'switch']) +def test_api_turn_off(hass, domain): + """Test api turn on process.""" + msg = smart_home.api_message( + 'TurnOffRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': '{}#test'.format(domain) + } + }) + + # settup test devices + hass.states.async_set( + '{}.test'.format(domain), 'on', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'turn_off') + + resp = yield from smart_home.async_api_turn_off(hass, msg) + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert resp['header']['name'] == 'TurnOffConfirmation' + + +@asyncio.coroutine +def test_api_set_percentage_light(hass): + """Test api set brightness process.""" + msg_light = smart_home.api_message( + 'SetPercentageRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': 'light#test' + }, + 'percentageState': { + 'value': '50' + } + }) + + # settup test devices + hass.states.async_set( + 'light.test', 'off', {'friendly_name': "Test light"}) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + resp = yield from smart_home.async_api_set_percentage(hass, msg_light) + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['brightness'] == '50' + assert resp['header']['name'] == 'SetPercentageConfirmation' From 308152f48cc156d7be91c7c6925e150b6aa63c83 Mon Sep 17 00:00:00 2001 From: Mike Christianson Date: Sat, 16 Sep 2017 12:59:49 -0700 Subject: [PATCH 201/277] fix for Twitter notifications without media (#9448) --- homeassistant/components/notify/twitter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index d4e969e95ec..6cb98e45274 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -75,7 +75,7 @@ class TwitterNotificationService(BaseNotificationService): self.upload_media_then_callback(callback, media) - def send_message_callback(self, message, media_id): + def send_message_callback(self, message, media_id=None): """Tweet a message, optionally with media.""" if self.user: resp = self.api.request('direct_messages/new', @@ -95,7 +95,7 @@ class TwitterNotificationService(BaseNotificationService): def upload_media_then_callback(self, callback, media_path=None): """Upload media.""" if not media_path: - return None + return callback() with open(media_path, 'rb') as file: total_bytes = os.path.getsize(media_path) From e2866a133958563c381c44805207606cf9ecdb2a Mon Sep 17 00:00:00 2001 From: Boyi C Date: Sun, 17 Sep 2017 04:00:54 +0800 Subject: [PATCH 202/277] Load WebComponent polyfill on header. (#9438) * Load WebComponent polyfill on header. On Chrome 53, `document.registerElement` exists but `window.customElements` does not exist. Fix for Tencent X5 browser on Android(Chrome 53 based). * Move the block just before app panel loading. Remove async for new script block. --- .../components/frontend/templates/index.html | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 6d199a86a50..70e7e777510 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -92,6 +92,18 @@ {% if not dev_mode %} {% endif %} + {% if panel_url -%} @@ -100,19 +112,5 @@ {% for extra_url in extra_urls -%} {% endfor -%} - - From 258ad8fc1644017649471cd0a3f5c29aec95b94b Mon Sep 17 00:00:00 2001 From: Paul Krischer Date: Sat, 16 Sep 2017 22:21:09 +0200 Subject: [PATCH 203/277] Fix issue 5728: Emulated Hue UPnP crashes on special characters. (#9453) --- homeassistant/components/emulated_hue/upnp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index f8d41424064..42a258cbf4b 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -136,7 +136,7 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 # because the data object has not been initialized continue - if "M-SEARCH" in data.decode('utf-8'): + if "M-SEARCH" in data.decode('utf-8', errors='ignore'): # SSDP M-SEARCH method received, respond to it with our info resp_socket = socket.socket( socket.AF_INET, socket.SOCK_DGRAM) From 840072e92f4fa23d75ecead1d4883be92c1ee379 Mon Sep 17 00:00:00 2001 From: pavoni Date: Sun, 17 Sep 2017 10:11:57 +0100 Subject: [PATCH 204/277] Bump pyvera - handle non english language controllers. --- homeassistant/components/vera.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 2183e20188f..7a018a6502d 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.35'] +REQUIREMENTS = ['pyvera==0.2.37'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d7907bbcf02..bcbec10c4f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -821,7 +821,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.35 +pyvera==0.2.37 # homeassistant.components.media_player.vizio pyvizio==0.0.2 From bda6d2c696b402e6e284a5b702141c4ddcae55d0 Mon Sep 17 00:00:00 2001 From: rbflurry Date: Sun, 17 Sep 2017 05:30:17 -0400 Subject: [PATCH 205/277] Ios notify camera fix (#9427) * Update __init__.py * Update ios.py * Update __init__.py --- homeassistant/components/ios.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index 13ccee9df3e..e3c58425b27 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -121,7 +121,7 @@ CONFIG_SCHEMA = vol.Schema({ CONF_PUSH: { CONF_PUSH_CATEGORIES: vol.All(cv.ensure_list, [{ vol.Required(CONF_PUSH_CATEGORIES_NAME): cv.string, - vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Upper, + vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Lower, vol.Required(CONF_PUSH_CATEGORIES_ACTIONS): ACTION_SCHEMA_LIST }]) } From 8a3f8457e8bfcf65b94aff3d7cc5e7053ac87bb3 Mon Sep 17 00:00:00 2001 From: Walter Huf Date: Sun, 17 Sep 2017 02:32:22 -0700 Subject: [PATCH 206/277] Adds MQTT Fan Discovery (#9463) --- homeassistant/components/fan/mqtt.py | 3 +++ homeassistant/components/mqtt/discovery.py | 3 ++- tests/components/mqtt/test_discovery.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 58ac08ce16f..e76e11d4786 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -78,6 +78,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT fan platform.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + async_add_devices([MqttFan( config.get(CONF_NAME), { diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index f76c4e9d527..7140423633e 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -20,10 +20,11 @@ TOPIC_MATCHER = re.compile( r'(?P\w+)/(?P\w+)/' r'(?:(?P[a-zA-Z0-9_-]+)/)?(?P[a-zA-Z0-9_-]+)/config') -SUPPORTED_COMPONENTS = ['binary_sensor', 'light', 'sensor', 'switch'] +SUPPORTED_COMPONENTS = ['binary_sensor', 'fan', 'light', 'sensor', 'switch'] ALLOWED_PLATFORMS = { 'binary_sensor': ['mqtt'], + 'fan': ['mqtt'], 'light': ['mqtt', 'mqtt_json', 'mqtt_template'], 'sensor': ['mqtt'], 'switch': ['mqtt'], diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index e865b524f85..d0704aac227 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -77,6 +77,23 @@ def test_correct_config_discovery(hass, mqtt_mock, caplog): assert ('binary_sensor', 'bla') in hass.data[ALREADY_DISCOVERED] +@asyncio.coroutine +def test_discover_fan(hass, mqtt_mock, caplog): + """Test discovering an MQTT fan.""" + yield from async_start(hass, 'homeassistant', {}) + + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + ('{ "name": "Beer",' + ' "command_topic": "test_topic" }')) + yield from hass.async_block_till_done() + + state = hass.states.get('fan.beer') + + assert state is not None + assert state.name == 'Beer' + assert ('fan', 'bla') in hass.data[ALREADY_DISCOVERED] + + @asyncio.coroutine def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): """Test sending in correct JSON with optional node_id included.""" From f2c605ba1b6f81759bc8c0c35399b4fa70e2afa5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 17 Sep 2017 13:40:25 +0200 Subject: [PATCH 207/277] Upgrade sqlalchemy to 1.1.14 (#9458) --- 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 6aac0b7fafd..5d3ca270399 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.13'] +REQUIREMENTS = ['sqlalchemy==1.1.14'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index bcbec10c4f6..33c0d7fa63f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -928,7 +928,7 @@ speedtest-cli==1.0.6 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.13 +sqlalchemy==1.1.14 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2a398d96f9..d5d6bbedca1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -133,7 +133,7 @@ somecomfort==0.4.1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.13 +sqlalchemy==1.1.14 # homeassistant.components.statsd statsd==3.2.1 From 6ccf039c9553e123fbacb4292ee0ea6a0ce3d862 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 17 Sep 2017 13:40:58 +0200 Subject: [PATCH 208/277] Upgrade uber_rides to 0.6.0 (#9457) --- homeassistant/components/sensor/uber.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/uber.py b/homeassistant/components/sensor/uber.py index eb7050309bc..e80fe7d2d82 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['uber_rides==0.5.2'] +REQUIREMENTS = ['uber_rides==0.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 33c0d7fa63f..ebd8f920010 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -978,7 +978,7 @@ transmissionrpc==0.11 twilio==5.7.0 # homeassistant.components.sensor.uber -uber_rides==0.5.2 +uber_rides==0.6.0 # homeassistant.components.sensor.ups upsmychoice==1.0.6 From 811f6b409290f4a47fc628d336dc99377c6ee367 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 17 Sep 2017 13:41:23 +0200 Subject: [PATCH 209/277] Upgrade youtube_dl to 2017.9.15 (#9456) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 1ecb09ac022..353eeae1607 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.9.2'] +REQUIREMENTS = ['youtube_dl==2017.9.15'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ebd8f920010..67170ccb0b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1044,7 +1044,7 @@ yeelight==0.3.2 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.9.2 +youtube_dl==2017.9.15 # homeassistant.components.light.zengge zengge==0.2 From 5f24cc229d94257ba92da48ba12abf90071c2e8f Mon Sep 17 00:00:00 2001 From: Andy Castille Date: Sun, 17 Sep 2017 13:47:30 -0500 Subject: [PATCH 210/277] DoorBird Component (#9281) * DoorBird Component * add newlines at end of files * fix lint * fix doorbird components conventions * fix doorbird domain import and log strings * don't redundantly add switches * Remove return statement from setup_platform --- .coveragerc | 3 + .../components/binary_sensor/doorbird.py | 60 ++++++++++++ homeassistant/components/camera/doorbird.py | 90 +++++++++++++++++ homeassistant/components/doorbird.py | 44 +++++++++ homeassistant/components/switch/doorbird.py | 97 +++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 297 insertions(+) create mode 100644 homeassistant/components/binary_sensor/doorbird.py create mode 100644 homeassistant/components/camera/doorbird.py create mode 100644 homeassistant/components/doorbird.py create mode 100644 homeassistant/components/switch/doorbird.py diff --git a/.coveragerc b/.coveragerc index 4f621763bec..1f4e705dd67 100644 --- a/.coveragerc +++ b/.coveragerc @@ -52,6 +52,9 @@ omit = homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py + + homeassistant/components/doorbird.py + homeassistant/components/*/doorbird.py homeassistant/components/dweet.py homeassistant/components/*/dweet.py diff --git a/homeassistant/components/binary_sensor/doorbird.py b/homeassistant/components/binary_sensor/doorbird.py new file mode 100644 index 00000000000..9a13687fc54 --- /dev/null +++ b/homeassistant/components/binary_sensor/doorbird.py @@ -0,0 +1,60 @@ +"""Support for reading binary states from a DoorBird video doorbell.""" +from datetime import timedelta +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.util import Throttle + +DEPENDENCIES = ['doorbird'] + +_LOGGER = logging.getLogger(__name__) +_MIN_UPDATE_INTERVAL = timedelta(milliseconds=250) + +SENSOR_TYPES = { + "doorbell": { + "name": "Doorbell Ringing", + "icon": { + True: "bell-ring", + False: "bell", + None: "bell-outline" + } + } +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DoorBird binary sensor component.""" + device = hass.data.get(DOORBIRD_DOMAIN) + add_devices([DoorBirdBinarySensor(device, "doorbell")], True) + + +class DoorBirdBinarySensor(BinarySensorDevice): + """A binary sensor of a DoorBird device.""" + + def __init__(self, device, sensor_type): + """Initialize a binary sensor on a DoorBird device.""" + self._device = device + self._sensor_type = sensor_type + self._state = None + + @property + def name(self): + """Get the name of the sensor.""" + return SENSOR_TYPES[self._sensor_type]["name"] + + @property + def icon(self): + """Get an icon to display.""" + state_icon = SENSOR_TYPES[self._sensor_type]["icon"][self._state] + return "mdi:{}".format(state_icon) + + @property + def is_on(self): + """Get the state of the binary sensor.""" + return self._state + + @Throttle(_MIN_UPDATE_INTERVAL) + def update(self): + """Pull the latest value from the device.""" + self._state = self._device.doorbell_state() diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py new file mode 100644 index 00000000000..cf6b6b2871f --- /dev/null +++ b/homeassistant/components/camera/doorbird.py @@ -0,0 +1,90 @@ +"""Support for viewing the camera feed from a DoorBird video doorbell.""" + +import asyncio +import datetime +import logging +import voluptuous as vol + +import aiohttp +import async_timeout + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DEPENDENCIES = ['doorbird'] + +_CAMERA_LIVE = "DoorBird Live" +_CAMERA_LAST_VISITOR = "DoorBird Last Ring" +_LIVE_INTERVAL = datetime.timedelta(seconds=1) +_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) +_LOGGER = logging.getLogger(__name__) +_TIMEOUT = 10 # seconds + +CONF_SHOW_LAST_VISITOR = 'last_visitor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SHOW_LAST_VISITOR, default=False): cv.boolean +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the DoorBird camera platform.""" + device = hass.data.get(DOORBIRD_DOMAIN) + + _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LIVE) + entities = [DoorBirdCamera(device.live_image_url, _CAMERA_LIVE, + _LIVE_INTERVAL)] + + if config.get(CONF_SHOW_LAST_VISITOR): + _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LAST_VISITOR) + entities.append(DoorBirdCamera(device.history_image_url(1), + _CAMERA_LAST_VISITOR, + _LAST_VISITOR_INTERVAL)) + + async_add_devices(entities) + _LOGGER.info("Added DoorBird camera(s)") + + +class DoorBirdCamera(Camera): + """The camera on a DoorBird device.""" + + def __init__(self, url, name, interval=None): + """Initialize the camera on a DoorBird device.""" + self._url = url + self._name = name + self._last_image = None + self._interval = interval or datetime.timedelta + self._last_update = datetime.datetime.min + super().__init__() + + @property + def name(self): + """Get the name of the camera.""" + return self._name + + @asyncio.coroutine + def async_camera_image(self): + """Pull a still image from the camera.""" + now = datetime.datetime.now() + + if self._last_image and now - self._last_update < self._interval: + return self._last_image + + try: + websession = async_get_clientsession(self.hass) + + with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): + response = yield from websession.get(self._url) + + self._last_image = yield from response.read() + self._last_update = now + return self._last_image + except asyncio.TimeoutError: + _LOGGER.error("Camera image timed out") + return self._last_image + except aiohttp.ClientError as error: + _LOGGER.error("Error getting camera image: %s", error) + return self._last_image diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py new file mode 100644 index 00000000000..421c85a0f94 --- /dev/null +++ b/homeassistant/components/doorbird.py @@ -0,0 +1,44 @@ +"""Support for a DoorBird video doorbell.""" + +import logging +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['DoorBirdPy==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'doorbird' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the DoorBird component.""" + device_ip = config[DOMAIN].get(CONF_HOST) + username = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + + from doorbirdpy import DoorBird + device = DoorBird(device_ip, username, password) + status = device.ready() + + if status[0]: + _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, username) + hass.data[DOMAIN] = device + return True + elif status[1] == 401: + _LOGGER.error("Authorization rejected by DoorBird at %s", device_ip) + return False + else: + _LOGGER.error("Could not connect to DoorBird at %s: Error %s", + device_ip, str(status[1])) + return False diff --git a/homeassistant/components/switch/doorbird.py b/homeassistant/components/switch/doorbird.py new file mode 100644 index 00000000000..66c3bf73116 --- /dev/null +++ b/homeassistant/components/switch/doorbird.py @@ -0,0 +1,97 @@ +"""Support for powering relays in a DoorBird video doorbell.""" +import datetime +import logging +import voluptuous as vol + +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.const import CONF_SWITCHES +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['doorbird'] + +_LOGGER = logging.getLogger(__name__) + +SWITCHES = { + "open_door": { + "name": "Open Door", + "icon": { + True: "lock-open", + False: "lock" + }, + "time": datetime.timedelta(seconds=3) + }, + "light_on": { + "name": "Light On", + "icon": { + True: "lightbulb-on", + False: "lightbulb" + }, + "time": datetime.timedelta(minutes=5) + } +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SWITCHES, default=[]): + vol.All(cv.ensure_list([vol.In(SWITCHES)])) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DoorBird switch platform.""" + device = hass.data.get(DOORBIRD_DOMAIN) + + switches = [] + for switch in SWITCHES: + _LOGGER.debug("Adding DoorBird switch %s", SWITCHES[switch]["name"]) + switches.append(DoorBirdSwitch(device, switch)) + + add_devices(switches) + _LOGGER.info("Added DoorBird switches") + + +class DoorBirdSwitch(SwitchDevice): + """A relay in a DoorBird device.""" + + def __init__(self, device, switch): + """Initialize a relay in a DoorBird device.""" + self._device = device + self._switch = switch + self._state = False + self._assume_off = datetime.datetime.min + + @property + def name(self): + """Get the name of the switch.""" + return SWITCHES[self._switch]["name"] + + @property + def icon(self): + """Get an icon to display.""" + return "mdi:{}".format(SWITCHES[self._switch]["icon"][self._state]) + + @property + def is_on(self): + """Get the assumed state of the relay.""" + return self._state + + def turn_on(self, **kwargs): + """Power the relay.""" + if self._switch == "open_door": + self._state = self._device.open_door() + elif self._switch == "light_on": + self._state = self._device.turn_light_on() + + now = datetime.datetime.now() + self._assume_off = now + SWITCHES[self._switch]["time"] + + def turn_off(self, **kwargs): + """The relays are time-based.""" + raise NotImplementedError("DoorBird relays cannot be manually turned " + "off.") + + def update(self): + """Wait for the correct amount of assumed time to pass.""" + if self._state and self._assume_off <= datetime.datetime.now(): + self._state = False + self._assume_off = datetime.datetime.min diff --git a/requirements_all.txt b/requirements_all.txt index 67170ccb0b2..96e45c89474 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -17,6 +17,9 @@ astral==1.4 # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.0.0 +# homeassistant.components.doorbird +DoorBirdPy==0.0.4 + # homeassistant.components.isy994 PyISY==1.0.8 From 2219dcaee5a3811740a0e8c1bc6bcb5296973b08 Mon Sep 17 00:00:00 2001 From: milanvo Date: Sun, 17 Sep 2017 21:10:53 +0200 Subject: [PATCH 211/277] Fix recorder does not vacuum SQLite DB on purge (#9469) --- homeassistant/components/recorder/purge.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 26ddefedf7d..90a69f8f2a1 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -26,6 +26,7 @@ def purge_old_data(instance, purge_days): _LOGGER.debug("Deleted %s events", deleted_rows) # Execute sqlite vacuum command to free up space on disk - if instance.engine.driver == 'sqlite': + _LOGGER.debug("DB engine driver: %s", instance.engine.driver) + if instance.engine.driver == 'pysqlite': _LOGGER.info("Vacuuming SQLite to free space") instance.engine.execute("VACUUM") From fd97c23cdeddee1ddaef1db87f70c624827d2dcc Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Sun, 17 Sep 2017 15:13:26 -0400 Subject: [PATCH 212/277] fitbit fixes (#9460) --- homeassistant/components/sensor/fitbit.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 5876a059672..1bb6383ecbb 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -37,8 +37,8 @@ CONF_ATTRIBUTION = 'Data provided by Fitbit.com' DEPENDENCIES = ['http'] -FITBIT_AUTH_CALLBACK_PATH = '/auth/fitbit/callback' -FITBIT_AUTH_START = '/auth/fitbit' +FITBIT_AUTH_CALLBACK_PATH = '/api/fitbit/callback' +FITBIT_AUTH_START = '/api/fitbit' FITBIT_CONFIG_FILE = 'fitbit.conf' FITBIT_DEFAULT_RESOURCES = ['activities/steps'] @@ -320,8 +320,8 @@ class FitbitAuthCallbackView(HomeAssistantView): """Handle OAuth finish callback requests.""" requires_auth = False - url = '/auth/fitbit/callback' - name = 'auth:fitbit:callback' + url = FITBIT_AUTH_CALLBACK_PATH + name = 'api:fitbit:callback' def __init__(self, config, add_devices, oauth): """Initialize the OAuth callback view.""" @@ -381,7 +381,8 @@ class FitbitAuthCallbackView(HomeAssistantView): ATTR_ACCESS_TOKEN: result.get('access_token'), ATTR_REFRESH_TOKEN: result.get('refresh_token'), ATTR_CLIENT_ID: self.oauth.client_id, - ATTR_CLIENT_SECRET: self.oauth.client_secret + ATTR_CLIENT_SECRET: self.oauth.client_secret, + ATTR_LAST_SAVED_AT: int(time.time()) } if not config_from_file(hass.config.path(FITBIT_CONFIG_FILE), config_contents): From 71e06c566f82b22295cd2aaa9cf458446d7102f8 Mon Sep 17 00:00:00 2001 From: Michael Prokop Date: Mon, 18 Sep 2017 07:45:07 +0200 Subject: [PATCH 213/277] Fix typo in services.yaml (#9475) s/varaible/variable/ --- homeassistant/components/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 57820917cab..5428155acc4 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -154,7 +154,7 @@ homematic: example: 'homematic.ccu2' name: - description: Name of the varaible to set + description: Name of the variable to set example: 'testvariable' value: From ced642c86206e4552aea6ed1e057c93712a5016c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 18 Sep 2017 07:45:27 +0200 Subject: [PATCH 214/277] Upgrade pyasn1 to 0.3.5 and pyasn1-modules to 0.1.4 (#9474) --- homeassistant/components/notify/xmpp.py | 4 ++-- requirements_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index f93e1b8f426..bcd1c0f3434 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -15,8 +15,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.3', - 'pyasn1-modules==0.1.1'] + 'pyasn1==0.3.5', + 'pyasn1-modules==0.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 96e45c89474..634bf94f280 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,10 +554,10 @@ pyalarmdotcom==0.3.0 pyarlo==0.0.4 # homeassistant.components.notify.xmpp -pyasn1-modules==0.1.1 +pyasn1-modules==0.1.4 # homeassistant.components.notify.xmpp -pyasn1==0.3.3 +pyasn1==0.3.5 # homeassistant.components.apple_tv pyatv==0.3.4 From 94dcf36d7cde9a55275e23f1a741cb742b105b57 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Mon, 18 Sep 2017 17:29:58 +0200 Subject: [PATCH 215/277] Xiaomi Gateway: Allow static configuration of a gateway without discovery (#9464) * Configuration parameter "host" introduced. Will skip the discovery of the host. * Provide a proper default port. Log message reformatted. * PyXiaomiGateway version bumped: The new feature was introduced with v0.4.0. * requirements_all.txt updated. * Native default for config parameter used. --- homeassistant/components/xiaomi_aqara.py | 15 +++++++++++++-- requirements_all.txt | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index f331ace06bd..53d3d3e7ad3 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -9,7 +9,7 @@ from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC) REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' - '0.3.2.zip#PyXiaomiGateway==0.3.2'] + '0.4.0.zip#PyXiaomiGateway==0.4.0'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' @@ -39,6 +39,17 @@ def _validate_conf(config): raise vol.Invalid('Invalid key %s.' ' Key must be 16 characters', key) res_gw_conf['key'] = key + + host = gw_conf.get('host') + if host is not None: + res_gw_conf['host'] = host + res_gw_conf['port'] = gw_conf.get('port', 9898) + + _LOGGER.warning( + 'Static address (%s:%s) of the gateway provided. ' + 'Discovery of this host will be skipped.', + res_gw_conf['host'], res_gw_conf['port']) + res_config.append(res_gw_conf) return res_config @@ -89,7 +100,7 @@ def setup(hass, config): _LOGGER.error("No gateway discovered") return False hass.data[PY_XIAOMI_GATEWAY].listen() - _LOGGER.debug("Listening for broadcast") + _LOGGER.debug("Gateways discovered. Listening for broadcasts") for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover']: discovery.load_platform(hass, component, DOMAIN, {}, config) diff --git a/requirements_all.txt b/requirements_all.txt index 634bf94f280..32c941a9384 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -302,7 +302,7 @@ holidays==0.8.1 http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a # homeassistant.components.xiaomi_aqara -https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.2.zip#PyXiaomiGateway==0.3.2 +https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.4.0.zip#PyXiaomiGateway==0.4.0 # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 From 77fb1baeb6388cd504aec794c773479f8887de65 Mon Sep 17 00:00:00 2001 From: Blender3D <452469+Blender3D@users.noreply.github.com> Date: Mon, 18 Sep 2017 11:33:58 -0400 Subject: [PATCH 216/277] Added support for the DTE Energy Bridge v2 (#9431) * Added optional 'version' option to switch between sensor versions. * Reduced line lengths * Removed error for invalid sensor version --- .../components/sensor/dte_energy_bridge.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/dte_energy_bridge.py b/homeassistant/components/sensor/dte_energy_bridge.py index 00da6c2ce51..c1687b6025b 100644 --- a/homeassistant/components/sensor/dte_energy_bridge.py +++ b/homeassistant/components/sensor/dte_energy_bridge.py @@ -16,14 +16,18 @@ from homeassistant.const import CONF_NAME _LOGGER = logging.getLogger(__name__) CONF_IP_ADDRESS = 'ip' +CONF_VERSION = 'version' DEFAULT_NAME = 'Current Energy Usage' +DEFAULT_VERSION = 1 ICON = 'mdi:flash' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_IP_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): + vol.All(vol.Coerce(int), vol.Any(1, 2)) }) @@ -31,16 +35,25 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DTE energy bridge sensor.""" name = config.get(CONF_NAME) ip_address = config.get(CONF_IP_ADDRESS) + version = config.get(CONF_VERSION, 1) - add_devices([DteEnergyBridgeSensor(ip_address, name)], True) + add_devices([DteEnergyBridgeSensor(ip_address, name, version)], True) class DteEnergyBridgeSensor(Entity): - """Implementation of a DTE Energy Bridge sensor.""" + """Implementation of the DTE Energy Bridge sensors.""" - def __init__(self, ip_address, name): + def __init__(self, ip_address, name, version): """Initialize the sensor.""" - self._url = "http://{}/instantaneousdemand".format(ip_address) + self._version = version + + if self._version == 1: + url_template = "http://{}/instantaneousdemand" + elif self._version == 2: + url_template = "http://{}:8888/zigbee/se/instantaneousdemand" + + self._url = url_template.format(ip_address) + self._name = name self._unit_of_measurement = "kW" self._state = None From 5851944f803e71ff2ce7801f094843ee1356438b Mon Sep 17 00:00:00 2001 From: Marcel Holle Date: Mon, 18 Sep 2017 17:35:35 +0200 Subject: [PATCH 217/277] Telnet switch (#8913) * Added telnet switch. * Lint. * Coverage * Added port parameter to Telnet switch. * Removed optimistic attribute from Telnet switch. * Code cleanup. --- .coveragerc | 1 + homeassistant/components/switch/telnet.py | 144 ++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 homeassistant/components/switch/telnet.py diff --git a/.coveragerc b/.coveragerc index 1f4e705dd67..a00599d7733 100644 --- a/.coveragerc +++ b/.coveragerc @@ -570,6 +570,7 @@ omit = homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py homeassistant/components/switch/tplink.py + homeassistant/components/switch/telnet.py homeassistant/components/switch/transmission.py homeassistant/components/switch/wake_on_lan.py homeassistant/components/telegram_bot/* diff --git a/homeassistant/components/switch/telnet.py b/homeassistant/components/switch/telnet.py new file mode 100644 index 00000000000..4d3db97f56e --- /dev/null +++ b/homeassistant/components/switch/telnet.py @@ -0,0 +1,144 @@ +""" +Support for switch controlled using a telnet connection. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.telnet/ +""" +import logging +import telnetlib +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA, + ENTITY_ID_FORMAT) +from homeassistant.const import ( + CONF_RESOURCE, CONF_NAME, CONF_SWITCHES, CONF_VALUE_TEMPLATE, + CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_COMMAND_STATE, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 23 + +SWITCH_SCHEMA = vol.Schema({ + vol.Required(CONF_COMMAND_ON): cv.string, + vol.Required(CONF_COMMAND_OFF): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_RESOURCE): cv.string, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), +}) + +SCAN_INTERVAL = timedelta(seconds=10) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Find and return switches controlled by telnet commands.""" + devices = config.get(CONF_SWITCHES, {}) + switches = [] + + for object_id, device_config in devices.items(): + value_template = device_config.get(CONF_VALUE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + + switches.append( + TelnetSwitch( + hass, + object_id, + device_config.get(CONF_RESOURCE), + device_config.get(CONF_PORT), + device_config.get(CONF_NAME, object_id), + device_config.get(CONF_COMMAND_ON), + device_config.get(CONF_COMMAND_OFF), + device_config.get(CONF_COMMAND_STATE), + value_template + ) + ) + + if not switches: + _LOGGER.error("No switches added") + return False + + add_devices(switches) + + +class TelnetSwitch(SwitchDevice): + """Representation of a switch that can be toggled using telnet commands.""" + + def __init__(self, hass, object_id, resource, port, friendly_name, + command_on, command_off, command_state, value_template): + """Initialize the switch.""" + self._hass = hass + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._resource = resource + self._port = port + self._name = friendly_name + self._state = False + self._command_on = command_on + self._command_off = command_off + self._command_state = command_state + self._value_template = value_template + + def _telnet_command(self, command): + try: + telnet = telnetlib.Telnet(self._resource, self._port) + telnet.write(command.encode('ASCII') + b'\r') + response = telnet.read_until(b'\r', timeout=0.2) + return response.decode('ASCII').strip() + except IOError as error: + _LOGGER.error( + 'Command "%s" failed with exception: %s', + command, repr(error)) + return None + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def should_poll(self): + """Only poll if we have state command.""" + return self._command_state is not None + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def assumed_state(self): + """Default ist true if no state command is defined, false otherwise.""" + return self._command_state is None + + def update(self): + """Update device state.""" + response = self._telnet_command(self._command_state) + if response: + rendered = self._value_template \ + .render_with_possible_json_value(response) + self._state = rendered == "True" + else: + _LOGGER.warning( + "Empty response for command: %s", self._command_state) + + def turn_on(self, **kwargs): + """Turn the device on.""" + self._telnet_command(self._command_on) + if self.assumed_state: + self._state = True + + def turn_off(self, **kwargs): + """Turn the device off.""" + self._telnet_command(self._command_off) + if self.assumed_state: + self._state = False From c44397e2578bcf4b0b5291eb8e2c491c548360fe Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Mon, 18 Sep 2017 08:39:41 -0700 Subject: [PATCH 218/277] Abode services, events, lights, cameras, automations, quick actions. (#9310) * Updated to latest AbodePy version. Added services and events. Added new device types. Added exclude, light, and polling config options. * Disable the event service if polling is enabled. * Addressed all CR's * Removed duplicated super call. * Name config option now used. Removed deprecated DEFAULT_NAME. * Modified partial to move event to first param. --- homeassistant/components/abode.py | 277 ++++++++++++++++-- .../components/alarm_control_panel/abode.py | 21 +- .../components/binary_sensor/abode.py | 69 +++-- homeassistant/components/camera/abode.py | 101 +++++++ homeassistant/components/cover/abode.py | 27 +- homeassistant/components/light/abode.py | 84 ++++++ homeassistant/components/lock/abode.py | 21 +- homeassistant/components/services.yaml | 29 ++ homeassistant/components/switch/abode.py | 53 +++- requirements_all.txt | 2 +- 10 files changed, 588 insertions(+), 96 deletions(-) create mode 100644 homeassistant/components/camera/abode.py create mode 100644 homeassistant/components/light/abode.py diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index f3283eff748..63c2fac48d1 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -6,57 +6,138 @@ https://home-assistant.io/components/abode/ """ import asyncio import logging +from functools import partial +from os import path import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.const import (ATTR_ATTRIBUTION, - CONF_USERNAME, CONF_PASSWORD, - CONF_NAME, EVENT_HOMEASSISTANT_STOP, +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, + ATTR_ENTITY_ID, CONF_USERNAME, CONF_PASSWORD, + CONF_EXCLUDE, CONF_NAME, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.9.0'] +REQUIREMENTS = ['abodepy==0.11.5'] _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by goabode.com" +CONF_LIGHTS = "lights" +CONF_POLLING = "polling" DOMAIN = 'abode' -DEFAULT_NAME = 'Abode' -DATA_ABODE = 'abode' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' +EVENT_ABODE_ALARM = 'abode_alarm' +EVENT_ABODE_ALARM_END = 'abode_alarm_end' +EVENT_ABODE_AUTOMATION = 'abode_automation' +EVENT_ABODE_FAULT = 'abode_panel_fault' +EVENT_ABODE_RESTORE = 'abode_panel_restore' + +SERVICE_SETTINGS = 'change_setting' +SERVICE_CAPTURE_IMAGE = 'capture_image' +SERVICE_TRIGGER = 'trigger_quick_action' + +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_DEVICE_TYPE = 'device_type' +ATTR_EVENT_CODE = 'event_code' +ATTR_EVENT_NAME = 'event_name' +ATTR_EVENT_TYPE = 'event_type' +ATTR_EVENT_UTC = 'event_utc' +ATTR_SETTING = 'setting' +ATTR_USER_NAME = 'user_name' +ATTR_VALUE = 'value' + +ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str]) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLLING, default=False): cv.boolean, + vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA, + vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA }), }, extra=vol.ALLOW_EXTRA) +CHANGE_SETTING_SCHEMA = vol.Schema({ + vol.Required(ATTR_SETTING): cv.string, + vol.Required(ATTR_VALUE): cv.string +}) + +CAPTURE_IMAGE_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + +TRIGGER_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + ABODE_PLATFORMS = [ - 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover' + 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover', + 'camera', 'light' ] +class AbodeSystem(object): + """Abode System class.""" + + def __init__(self, username, password, name, polling, exclude, lights): + """Initialize the system.""" + import abodepy + self.abode = abodepy.Abode(username, password, + auto_login=True, + get_devices=True, + get_automations=True) + self.name = name + self.polling = polling + self.exclude = exclude + self.lights = lights + self.devices = [] + + def is_excluded(self, device): + """Check if a device is configured to be excluded.""" + return device.device_id in self.exclude + + def is_automation_excluded(self, automation): + """Check if an automation is configured to be excluded.""" + return automation.automation_id in self.exclude + + def is_light(self, device): + """Check if a switch device is configured as a light.""" + import abodepy.helpers.constants as CONST + + return (device.generic_type == CONST.TYPE_LIGHT or + (device.generic_type == CONST.TYPE_SWITCH and + device.device_id in self.lights)) + + def setup(hass, config): """Set up Abode component.""" - import abodepy + from abodepy.exceptions import AbodeException conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) + name = conf.get(CONF_NAME) + polling = conf.get(CONF_POLLING) + exclude = conf.get(CONF_EXCLUDE) + lights = conf.get(CONF_LIGHTS) try: - hass.data[DATA_ABODE] = abode = abodepy.Abode( - username, password, auto_login=True, get_devices=True) - - except (ConnectTimeout, HTTPError) as ex: + hass.data[DOMAIN] = AbodeSystem( + username, password, name, polling, exclude, lights) + except (AbodeException, ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + hass.components.persistent_notification.create( 'Error: {}
' 'You will need to restart hass after fixing.' @@ -65,46 +146,144 @@ def setup(hass, config): notification_id=NOTIFICATION_ID) return False + setup_hass_services(hass) + setup_hass_events(hass) + setup_abode_events(hass) + for platform in ABODE_PLATFORMS: discovery.load_platform(hass, platform, DOMAIN, {}, config) + return True + + +def setup_hass_services(hass): + """Home assistant services.""" + from abodepy.exceptions import AbodeException + + def change_setting(call): + """Change an Abode system setting.""" + setting = call.data.get(ATTR_SETTING) + value = call.data.get(ATTR_VALUE) + + try: + hass.data[DOMAIN].abode.set_setting(setting, value) + except AbodeException as ex: + _LOGGER.warning(ex) + + def capture_image(call): + """Capture a new image.""" + entity_ids = call.data.get(ATTR_ENTITY_ID) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.capture() + + def trigger_quick_action(call): + """Trigger a quick action.""" + entity_ids = call.data.get(ATTR_ENTITY_ID, None) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.trigger() + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml'))[DOMAIN] + + hass.services.register( + DOMAIN, SERVICE_SETTINGS, change_setting, + descriptions.get(SERVICE_SETTINGS), + schema=CHANGE_SETTING_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, + descriptions.get(SERVICE_CAPTURE_IMAGE), + schema=CAPTURE_IMAGE_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_TRIGGER, trigger_quick_action, + descriptions.get(SERVICE_TRIGGER), + schema=TRIGGER_SCHEMA) + + +def setup_hass_events(hass): + """Home assistant start and stop callbacks.""" + def startup(event): + """Listen for push events.""" + hass.data[DOMAIN].abode.events.start() + def logout(event): """Logout of Abode.""" - abode.stop_listener() - abode.logout() + if not hass.data[DOMAIN].polling: + hass.data[DOMAIN].abode.events.stop() + + hass.data[DOMAIN].abode.logout() _LOGGER.info("Logged out of Abode") + if not hass.data[DOMAIN].polling: + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) - def startup(event): - """Listen for push events.""" - abode.start_listener() - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) +def setup_abode_events(hass): + """Event callbacks.""" + import abodepy.helpers.timeline as TIMELINE - return True + def event_callback(event, event_json): + """Handle an event callback from Abode.""" + data = { + ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''), + ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''), + ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''), + ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''), + ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''), + ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''), + ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''), + ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''), + ATTR_DATE: event_json.get(ATTR_DATE, ''), + ATTR_TIME: event_json.get(ATTR_TIME, ''), + } + + hass.bus.fire(event, data) + + events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP, + TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP, + TIMELINE.AUTOMATION_GROUP] + + for event in events: + hass.data[DOMAIN].abode.events.add_event_callback( + event, + partial(event_callback, event)) class AbodeDevice(Entity): """Representation of an Abode device.""" - def __init__(self, controller, device): + def __init__(self, data, device): """Initialize a sensor for Abode device.""" - self._controller = controller + self._data = data self._device = device @asyncio.coroutine def async_added_to_hass(self): """Subscribe Abode events.""" self.hass.async_add_job( - self._controller.register, self._device, - self._update_callback + self._data.abode.events.add_device_callback, + self._device.device_id, self._update_callback ) @property def should_poll(self): """Return the polling state.""" - return False + return self._data.polling + + def update(self): + """Update automation state.""" + self._device.refresh() @property def name(self): @@ -124,3 +303,51 @@ class AbodeDevice(Entity): def _update_callback(self, device): """Update the device state.""" self.schedule_update_ha_state() + + +class AbodeAutomation(Entity): + """Representation of an Abode automation.""" + + def __init__(self, data, automation, event=None): + """Initialize for Abode automation.""" + self._data = data + self._automation = automation + self._event = event + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + if self._event: + self.hass.async_add_job( + self._data.abode.events.add_event_callback, + self._event, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._automation.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._automation.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'automation_id': self._automation.automation_id, + 'type': self._automation.type, + 'sub_type': self._automation.sub_type + } + + def _update_callback(self, device): + """Update the device state.""" + self._automation.refresh() + self.schedule_update_ha_state() diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py index 7a615ffc7bf..aa4e86a2318 100644 --- a/homeassistant/components/alarm_control_panel/abode.py +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/alarm_control_panel.abode/ import logging from homeassistant.components.abode import ( - AbodeDevice, DATA_ABODE, DEFAULT_NAME, CONF_ATTRIBUTION) + AbodeDevice, DOMAIN as ABODE_DOMAIN, CONF_ATTRIBUTION) from homeassistant.components.alarm_control_panel import (AlarmControlPanel) from homeassistant.const import (ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) @@ -22,18 +22,22 @@ ICON = 'mdi:security' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - add_devices([AbodeAlarm(abode, abode.get_alarm())]) + alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)] + + data.devices.extend(alarm_devices) + + add_devices(alarm_devices) class AbodeAlarm(AbodeDevice, AlarmControlPanel): """An alarm_control_panel implementation for Abode.""" - def __init__(self, controller, device): + def __init__(self, data, device, name): """Initialize the alarm control panel.""" - AbodeDevice.__init__(self, controller, device) - self._name = "{0}".format(DEFAULT_NAME) + super().__init__(data, device) + self._name = name @property def icon(self): @@ -65,6 +69,11 @@ class AbodeAlarm(AbodeDevice, AlarmControlPanel): """Send arm away command.""" self._device.set_away() + @property + def name(self): + """Return the name of the alarm.""" + return self._name or super().name + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/binary_sensor/abode.py b/homeassistant/components/binary_sensor/abode.py index d3b0d662a94..8ad40158958 100644 --- a/homeassistant/components/binary_sensor/abode.py +++ b/homeassistant/components/binary_sensor/abode.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/binary_sensor.abode/ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) from homeassistant.components.binary_sensor import BinarySensorDevice @@ -17,39 +18,38 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - abode = hass.data[DATA_ABODE] - - device_types = map_abode_device_class().keys() - - sensors = [] - for sensor in abode.get_devices(type_filter=device_types): - sensors.append(AbodeBinarySensor(abode, sensor)) - - add_devices(sensors) - - -def map_abode_device_class(): - """Map Abode device types to Home Assistant binary sensor class.""" import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE - return { - CONST.DEVICE_GLASS_BREAK: 'connectivity', - CONST.DEVICE_KEYPAD: 'connectivity', - CONST.DEVICE_DOOR_CONTACT: 'opening', - CONST.DEVICE_STATUS_DISPLAY: 'connectivity', - CONST.DEVICE_MOTION_CAMERA: 'connectivity', - CONST.DEVICE_WATER_SENSOR: 'moisture' - } + data = hass.data[ABODE_DOMAIN] + + device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE, + CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY, + CONST.TYPE_OPENING] + + devices = [] + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device): + continue + + devices.append(AbodeBinarySensor(data, device)) + + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_QUICK_ACTION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeQuickActionBinarySensor( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): """A binary sensor implementation for Abode device.""" - def __init__(self, controller, device): - """Initialize a sensor for Abode device.""" - AbodeDevice.__init__(self, controller, device) - self._device_class = map_abode_device_class().get(self._device.type) - @property def is_on(self): """Return True if the binary sensor is on.""" @@ -58,4 +58,17 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): @property def device_class(self): """Return the class of the binary sensor.""" - return self._device_class + return self._device.generic_type + + +class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): + """A binary sensor implementation for Abode quick action automations.""" + + def trigger(self): + """Trigger a quick automation.""" + self._automation.trigger() + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant/components/camera/abode.py b/homeassistant/components/camera/abode.py new file mode 100644 index 00000000000..3c0c0a54e0e --- /dev/null +++ b/homeassistant/components/camera/abode.py @@ -0,0 +1,101 @@ +""" +This component provides HA camera support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.abode/ +""" +import asyncio +import logging + +from datetime import timedelta +import requests + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.camera import Camera +from homeassistant.util import Throttle + + +DEPENDENCIES = ['abode'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discoveryy_info=None): + """Set up Abode camera devices.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA): + if data.is_excluded(device): + continue + + devices.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeCamera(AbodeDevice, Camera): + """Representation of an Abode camera.""" + + def __init__(self, data, device, event): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, data, device) + Camera.__init__(self) + self._event = event + self._response = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + yield from super().async_added_to_hass() + + self.hass.async_add_job( + self._data.abode.events.add_timeline_callback, + self._event, self._capture_callback + ) + + def capture(self): + """Request a new image capture.""" + return self._device.capture() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_image(self): + """Find a new image on the timeline.""" + if self._device.refresh_image(): + self.get_image() + + def get_image(self): + """Attempt to download the most recent capture.""" + if self._device.image_url: + try: + self._response = requests.get( + self._device.image_url, stream=True) + + self._response.raise_for_status() + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + else: + self._response = None + + def camera_image(self): + """Get a camera image.""" + self.refresh_image() + + if self._response: + return self._response.content + + return None + + def _capture_callback(self, capture): + """Update the image with the device then refresh device.""" + self._device.update_image_location(capture) + self.get_image() + self.schedule_update_ha_state() diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py index b09c9e5e007..6eb0369aa3f 100644 --- a/homeassistant/components/cover/abode.py +++ b/homeassistant/components/cover/abode.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/cover.abode/ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.cover import CoverDevice @@ -19,31 +19,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Abode cover devices.""" import abodepy.helpers.constants as CONST - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - sensors = [] - for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)): - sensors.append(AbodeCover(abode, sensor)) + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): + if data.is_excluded(device): + continue - add_devices(sensors) + devices.append(AbodeCover(data, device)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeCover(AbodeDevice, CoverDevice): """Representation of an Abode cover.""" - def __init__(self, controller, device): - """Initialize the Abode device.""" - AbodeDevice.__init__(self, controller, device) - @property def is_closed(self): """Return true if cover is closed, else False.""" - return self._device.is_open is False + return not self._device.is_open - def close_cover(self): + def close_cover(self, **kwargs): """Issue close command to cover.""" self._device.close_cover() - def open_cover(self): + def open_cover(self, **kwargs): """Issue open command to cover.""" self._device.open_cover() diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py new file mode 100644 index 00000000000..d3e79b38647 --- /dev/null +++ b/homeassistant/components/light/abode.py @@ -0,0 +1,84 @@ +""" +This component provides HA light support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, Light) + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode light devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + device_types = [CONST.TYPE_LIGHT, CONST.TYPE_SWITCH] + + devices = [] + + # Get all regular lights that are not excluded or switches marked as lights + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device) or not data.is_light(device): + continue + + devices.append(AbodeLight(data, device)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeLight(AbodeDevice, Light): + """Representation of an Abode light.""" + + def turn_on(self, **kwargs): + """Turn on the light.""" + if (ATTR_RGB_COLOR in kwargs and + self._device.is_dimmable and self._device.has_color): + self._device.set_color(kwargs[ATTR_RGB_COLOR]) + elif ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: + self._device.set_level(kwargs[ATTR_BRIGHTNESS]) + else: + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the light.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + @property + def brightness(self): + """Return the brightness of the light.""" + if self._device.is_dimmable and self._device.has_brightness: + return self._device.brightness + + @property + def rgb_color(self): + """Return the color of the light.""" + if self._device.is_dimmable and self._device.has_color: + return self._device.color + + @property + def supported_features(self): + """Flag supported features.""" + if self._device.is_dimmable and self._device.has_color: + return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + elif self._device.is_dimmable: + return SUPPORT_BRIGHTNESS + + return 0 diff --git a/homeassistant/components/lock/abode.py b/homeassistant/components/lock/abode.py index aad720e0d7d..2d342326636 100644 --- a/homeassistant/components/lock/abode.py +++ b/homeassistant/components/lock/abode.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/lock.abode/ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.lock import LockDevice @@ -19,22 +19,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Abode lock devices.""" import abodepy.helpers.constants as CONST - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - sensors = [] - for sensor in abode.get_devices(type_filter=(CONST.DEVICE_DOOR_LOCK)): - sensors.append(AbodeLock(abode, sensor)) + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_LOCK): + if data.is_excluded(device): + continue - add_devices(sensors) + devices.append(AbodeLock(data, device)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeLock(AbodeDevice, LockDevice): """Representation of an Abode lock.""" - def __init__(self, controller, device): - """Initialize the Abode device.""" - AbodeDevice.__init__(self, controller, device) - def lock(self, **kwargs): """Lock the device.""" self._device.lock() diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 5428155acc4..545a883be8f 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -571,3 +571,32 @@ counter: entity_id: description: Entity id of the counter to reset. example: 'counter.count0' + +abode: + change_setting: + description: Change an Abode system setting. + + fields: + setting: + description: Setting to change. + example: 'beeper_mute' + + value: + description: Value of the setting. + example: '1' + + capture_image: + description: Request a new image capture from a camera device. + + fields: + entity_id: + description: Entity id of the camera to request an image. + example: 'camera.downstairs_motion_camera' + + trigger_quick_action: + description: Trigger an Abode quick action. + + fields: + entity_id: + description: Entity id of the quick action to trigger. + example: 'binary_sensor.home_quick_action' diff --git a/homeassistant/components/switch/abode.py b/homeassistant/components/switch/abode.py index bed0b9c0b60..63fe6b9f7b8 100644 --- a/homeassistant/components/switch/abode.py +++ b/homeassistant/components/switch/abode.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/switch.abode/ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) from homeassistant.components.switch import SwitchDevice @@ -18,27 +19,36 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Abode switch devices.""" import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - device_types = [ - CONST.DEVICE_POWER_SWITCH_SENSOR, - CONST.DEVICE_POWER_SWITCH_METER] + devices = [] - sensors = [] - for sensor in abode.get_devices(type_filter=device_types): - sensors.append(AbodeSwitch(abode, sensor)) + # Get all regular switches that are not excluded or marked as lights + for device in data.abode.get_devices(generic_type=CONST.TYPE_SWITCH): + if data.is_excluded(device) or not data.is_light(device): + continue - add_devices(sensors) + devices.append(AbodeSwitch(data, device)) + + # Get all Abode automations that can be enabled/disabled + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_AUTOMATION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeAutomationSwitch( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeSwitch(AbodeDevice, SwitchDevice): """Representation of an Abode switch.""" - def __init__(self, controller, device): - """Initialize the Abode device.""" - AbodeDevice.__init__(self, controller, device) - def turn_on(self, **kwargs): """Turn on the device.""" self._device.switch_on() @@ -51,3 +61,20 @@ class AbodeSwitch(AbodeDevice, SwitchDevice): def is_on(self): """Return true if device is on.""" return self._device.is_on + + +class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice): + """A switch implementation for Abode automations.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._automation.set_active(True) + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._automation.set_active(False) + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/requirements_all.txt b/requirements_all.txt index 32c941a9384..7e88616f673 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -42,7 +42,7 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.9.0 +abodepy==0.11.5 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.3 From 3996c609b41abd8fdd7efbfef00762a94b0ddf2d Mon Sep 17 00:00:00 2001 From: c-soft Date: Mon, 18 Sep 2017 17:42:31 +0200 Subject: [PATCH 219/277] Added satel_integra alarm panel and binary sensor platform (#9336) * Added satel_integra alarm panel and binary sensor platform * Fixed several issues after review: import cleanup, reduced messaging levels to debug, other. * Fixes after review: removed dead code, improved loop, sorted imports. * Changes after review, not yet working * Changes after review - wrapped async code, killed ensure_future, moved async_load_platform into jobs --- .coveragerc | 3 + .../alarm_control_panel/satel_integra.py | 94 +++++++++++ .../components/binary_sensor/satel_integra.py | 90 +++++++++++ homeassistant/components/satel_integra.py | 152 ++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 342 insertions(+) create mode 100644 homeassistant/components/alarm_control_panel/satel_integra.py create mode 100644 homeassistant/components/binary_sensor/satel_integra.py create mode 100644 homeassistant/components/satel_integra.py diff --git a/.coveragerc b/.coveragerc index a00599d7733..239c155d7ac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -161,6 +161,9 @@ omit = homeassistant/components/rpi_pfio.py homeassistant/components/*/rpi_pfio.py + homeassistant/components/satel_integra.py + homeassistant/components/*/satel_integra.py + homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py new file mode 100644 index 00000000000..6115311f873 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/satel_integra.py @@ -0,0 +1,94 @@ +""" +Support for Satel Integra alarm, using ETHM module: https://www.satel.pl/en/ . + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.satel_integra/ +""" +import asyncio +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.satel_integra import (CONF_ARM_HOME_MODE, + DATA_SATEL, + SIGNAL_PANEL_MESSAGE) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['satel_integra'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up for AlarmDecoder alarm panels.""" + if not discovery_info: + return + + device = SatelIntegraAlarmPanel("Alarm Panel", + discovery_info.get(CONF_ARM_HOME_MODE)) + async_add_devices([device]) + + +class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): + """Representation of an AlarmDecoder-based alarm panel.""" + + def __init__(self, name, arm_home_mode): + """Initialize the alarm panel.""" + self._name = name + self._state = None + self._arm_home_mode = arm_home_mode + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + + @callback + def _message_callback(self, message): + + if message != self._state: + self._state = message + self.async_schedule_update_ha_state() + else: + _LOGGER.warning("Ignoring alarm status message, same state") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def code_format(self): + """Return the regex for code format or None if no code is required.""" + return '^\\d{4,6}$' + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @asyncio.coroutine + def async_alarm_disarm(self, code=None): + """Send disarm command.""" + if code: + yield from self.hass.data[DATA_SATEL].disarm(code) + + @asyncio.coroutine + def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code) + + @asyncio.coroutine + def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code, + self._arm_home_mode) diff --git a/homeassistant/components/binary_sensor/satel_integra.py b/homeassistant/components/binary_sensor/satel_integra.py new file mode 100644 index 00000000000..f373809f7c0 --- /dev/null +++ b/homeassistant/components/binary_sensor/satel_integra.py @@ -0,0 +1,90 @@ +""" +Support for Satel Integra zone states- represented as binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.satel_integra/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.satel_integra import (CONF_ZONES, + CONF_ZONE_NAME, + CONF_ZONE_TYPE, + SIGNAL_ZONES_UPDATED) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['satel_integra'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Satel Integra binary sensor devices.""" + if not discovery_info: + return + + configured_zones = discovery_info[CONF_ZONES] + + devices = [] + + for zone_num, device_config_data in configured_zones.items(): + zone_type = device_config_data[CONF_ZONE_TYPE] + zone_name = device_config_data[CONF_ZONE_NAME] + device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type) + devices.append(device) + + async_add_devices(devices) + + +class SatelIntegraBinarySensor(BinarySensorDevice): + """Representation of an Satel Integra binary sensor.""" + + def __init__(self, zone_number, zone_name, zone_type): + """Initialize the binary_sensor.""" + self._zone_number = zone_number + self._name = zone_name + self._zone_type = zone_type + self._state = 0 + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_ZONES_UPDATED, self._zones_updated) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def icon(self): + """Icon for device by its type.""" + if self._zone_type == 'smoke': + return "mdi:fire" + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state == 1 + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + @callback + def _zones_updated(self, zones): + """Update the zone's state, if needed.""" + if self._zone_number in zones \ + and self._state != zones[self._zone_number]: + self._state = zones[self._zone_number] + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/satel_integra.py b/homeassistant/components/satel_integra.py new file mode 100644 index 00000000000..4b61ff15c08 --- /dev/null +++ b/homeassistant/components/satel_integra.py @@ -0,0 +1,152 @@ +""" +Support for Satel Integra devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/satel_integra/ +""" +# pylint: disable=invalid-name + +import asyncio +import logging + + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send + +REQUIREMENTS = ['satel_integra==0.1.0'] + +DEFAULT_ALARM_NAME = 'satel_integra' +DEFAULT_PORT = 7094 +DEFAULT_CONF_ARM_HOME_MODE = 1 +DEFAULT_DEVICE_PARTITION = 1 +DEFAULT_ZONE_TYPE = 'motion' + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'satel_integra' + +DATA_SATEL = 'satel_integra' + +CONF_DEVICE_HOST = 'host' +CONF_DEVICE_PORT = 'port' +CONF_DEVICE_PARTITION = 'partition' +CONF_ARM_HOME_MODE = 'arm_home_mode' +CONF_ZONE_NAME = 'name' +CONF_ZONE_TYPE = 'type' +CONF_ZONES = 'zones' + +ZONES = 'zones' + +SIGNAL_PANEL_MESSAGE = 'satel_integra.panel_message' +SIGNAL_PANEL_ARM_AWAY = 'satel_integra.panel_arm_away' +SIGNAL_PANEL_ARM_HOME = 'satel_integra.panel_arm_home' +SIGNAL_PANEL_DISARM = 'satel_integra.panel_disarm' + +SIGNAL_ZONES_UPDATED = 'satel_integra.zones_updated' + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_NAME): cv.string, + vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE_HOST): cv.string, + vol.Optional(CONF_DEVICE_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_DEVICE_PARTITION, + default=DEFAULT_DEVICE_PARTITION): cv.positive_int, + vol.Optional(CONF_ARM_HOME_MODE, + default=DEFAULT_CONF_ARM_HOME_MODE): vol.In([1, 2, 3]), + vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, + }), +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the Satel Integra component.""" + conf = config.get(DOMAIN) + + zones = conf.get(CONF_ZONES) + host = conf.get(CONF_DEVICE_HOST) + port = conf.get(CONF_DEVICE_PORT) + partition = conf.get(CONF_DEVICE_PARTITION) + + from satel_integra.satel_integra import AsyncSatel, AlarmState + + controller = AsyncSatel(host, port, zones, hass.loop, partition) + + hass.data[DATA_SATEL] = controller + + result = yield from controller.connect() + + if not result: + return False + + @asyncio.coroutine + def _close(): + controller.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close()) + + _LOGGER.debug("Arm home config: %s, mode: %s ", + conf, + conf.get(CONF_ARM_HOME_MODE)) + + task_control_panel = hass.async_add_job( + async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config)) + + task_zones = hass.async_add_job( + async_load_platform(hass, 'binary_sensor', DOMAIN, + {CONF_ZONES: zones}, config)) + + yield from asyncio.wait([task_control_panel, task_zones], loop=hass.loop) + + @callback + def alarm_status_update_callback(status): + """Send status update received from alarm to home assistant.""" + _LOGGER.debug("Alarm status callback, status: %s", status) + hass_alarm_status = STATE_ALARM_DISARMED + + if status == AlarmState.ARMED_MODE0: + hass_alarm_status = STATE_ALARM_ARMED_AWAY + + elif status in [ + AlarmState.ARMED_MODE0, + AlarmState.ARMED_MODE1, + AlarmState.ARMED_MODE2, + AlarmState.ARMED_MODE3 + ]: + hass_alarm_status = STATE_ALARM_ARMED_HOME + + elif status in [AlarmState.TRIGGERED, AlarmState.TRIGGERED_FIRE]: + hass_alarm_status = STATE_ALARM_TRIGGERED + + elif status == AlarmState.DISARMED: + hass_alarm_status = STATE_ALARM_DISARMED + + _LOGGER.debug("Sending hass_alarm_status: %s...", hass_alarm_status) + async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, hass_alarm_status) + + @callback + def zones_update_callback(status): + """Update zone objects as per notification from the alarm.""" + _LOGGER.debug("Zones callback , status: %s", status) + async_dispatcher_send(hass, SIGNAL_ZONES_UPDATED, status[ZONES]) + + # Create a task instead of adding a tracking job, since this task will + # run until the connection to satel_integra is closed. + hass.loop.create_task(controller.keep_alive()) + hass.loop.create_task( + controller.monitor_status( + alarm_status_update_callback, + zones_update_callback) + ) + + return True diff --git a/requirements_all.txt b/requirements_all.txt index 7e88616f673..b58c1f846cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -883,6 +883,9 @@ rxv==0.4.0 # homeassistant.components.media_player.samsungtv samsungctl==0.6.0 +# homeassistant.components.satel_integra +satel_integra==0.1.0 + # homeassistant.components.sensor.deutsche_bahn schiene==0.18 From 392588e51979ad8bc1a1f6f177354626500289cf Mon Sep 17 00:00:00 2001 From: nilzen Date: Mon, 18 Sep 2017 17:47:23 +0200 Subject: [PATCH 220/277] Worx Landroid sensor (#9416) * Worx Landroid sensor * Move component into sensor folder * Update .coveragerc * Remove incorrect file * Code cosmetics * Code cosmetics * Trailing whitespace * Add docstrings and update module name * Remove hyphen in component file name * Fix redefined-builtin and no-self-use * Update filename in .coveragerc * Fixed pvizelis requested changes * Update worxlandroid.py --- .coveragerc | 1 + .../components/sensor/worxlandroid.py | 165 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 homeassistant/components/sensor/worxlandroid.py diff --git a/.coveragerc b/.coveragerc index 239c155d7ac..60375fbb97e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -548,6 +548,7 @@ omit = homeassistant/components/sensor/vasttrafik.py homeassistant/components/sensor/waqi.py homeassistant/components/sensor/worldtidesinfo.py + homeassistant/components/sensor/worxlandroid.py homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/yweather.py homeassistant/components/sensor/zamg.py diff --git a/homeassistant/components/sensor/worxlandroid.py b/homeassistant/components/sensor/worxlandroid.py new file mode 100644 index 00000000000..324771c163c --- /dev/null +++ b/homeassistant/components/sensor/worxlandroid.py @@ -0,0 +1,165 @@ +""" +Support for Worx Landroid mower. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.worxlandroid/ +""" +import logging +import asyncio + +import aiohttp +import async_timeout + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +from homeassistant.helpers.entity import Entity +from homeassistant.components.switch import (PLATFORM_SCHEMA) +from homeassistant.const import (CONF_HOST, CONF_PIN, CONF_TIMEOUT) +from homeassistant.helpers.aiohttp_client import (async_get_clientsession) + +_LOGGER = logging.getLogger(__name__) + +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' + +DEFAULT_TIMEOUT = 5 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PIN): + vol.All(vol.Coerce(int), vol.Range(min=1000, max=9999)), + vol.Optional(CONF_ALLOW_UNREACHABLE, default=True): cv.boolean, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, +}) + +ERROR_STATE = [ + 'blade-blocked', + 'repositioning-error', + 'wire-bounced', + 'blade-blocked', + 'outside-wire', + 'mower-lifted', + 'alarm-6', + 'upside-down', + 'alarm-8', + 'collision-sensor-blocked', + 'mower-tilted', + 'charge-error', + 'battery-error' +] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Worx Landroid sensors.""" + for typ in ('battery', 'state'): + async_add_entities([WorxLandroidSensor(typ, config)]) + + +class WorxLandroidSensor(Entity): + """Implementation of a Worx Landroid sensor.""" + + def __init__(self, sensor, config): + """Initialize a Worx Landroid sensor.""" + self._state = None + self.sensor = sensor + self.host = config.get(CONF_HOST) + self.pin = config.get(CONF_PIN) + self.timeout = config.get(CONF_TIMEOUT) + self.allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE) + self.url = 'http://{}/jsondata.cgi'.format(self.host) + + @property + def name(self): + """Return the name of the sensor.""" + return 'worxlandroid-{}'.format(self.sensor) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + if self.sensor == 'battery': + return '%' + else: + return None + + @asyncio.coroutine + def async_update(self): + """Update the sensor data from the mower.""" + connection_error = False + + try: + session = async_get_clientsession(self.hass) + with async_timeout.timeout(self.timeout, loop=self.hass.loop): + auth = aiohttp.helpers.BasicAuth('admin', self.pin) + mower_response = yield from session.get(self.url, auth=auth) + except (asyncio.TimeoutError, aiohttp.ClientError): + if self.allow_unreachable is False: + _LOGGER.error("Error connecting to mower at %s", self.url) + + connection_error = True + + # connection error + if connection_error is True and self.allow_unreachable is False: + if self.sensor == 'error': + self._state = 'yes' + elif self.sensor == 'state': + self._state = 'connection-error' + + # connection success + elif connection_error is False: + # set the expected content type to be text/html + # since the mover incorrectly returns it... + data = yield from mower_response.json(content_type='text/html') + + # sensor battery + if self.sensor == 'battery': + self._state = data['perc_batt'] + + # sensor error + elif self.sensor == 'error': + self._state = 'no' if self.get_error(data) is None else 'yes' + + # sensor state + elif self.sensor == 'state': + self._state = self.get_state(data) + + else: + if self.sensor == 'error': + self._state = 'no' + + @staticmethod + def get_error(obj): + """Get the mower error.""" + for i, err in enumerate(obj['allarmi']): + if i != 2: # ignore wire bounce errors + if err == 1: + return ERROR_STATE[i] + + return None + + def get_state(self, obj): + """Get the state of the mower.""" + state = self.get_error(obj) + + if state is None: + state_obj = obj['settaggi'] + + if state_obj[14] == 1: + return 'manual-stop' + elif state_obj[5] == 1 and state_obj[13] == 0: + return 'charging' + elif state_obj[5] == 1 and state_obj[13] == 1: + return 'charging-complete' + elif state_obj[15] == 1: + return 'going-home' + else: + return 'mowing' + + return state From 15c3ea0d863525b2d820a4fe046977bed95c7dd0 Mon Sep 17 00:00:00 2001 From: Colin Dunn Date: Tue, 19 Sep 2017 05:42:31 +1000 Subject: [PATCH 221/277] Fix universal media_player mute (#9462) --- homeassistant/components/media_player/universal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index e25f9d18252..b79c708c33c 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -422,12 +422,12 @@ class UniversalMediaPlayer(MediaPlayerDevice): """ return self._async_call_service(SERVICE_TURN_OFF, allow_override=True) - def async_mute_volume(self, is_volume_muted): + def async_mute_volume(self, mute): """Mute the volume. This method must be run in the event loop and returns a coroutine. """ - data = {ATTR_MEDIA_VOLUME_MUTED: is_volume_muted} + data = {ATTR_MEDIA_VOLUME_MUTED: mute} return self._async_call_service( SERVICE_VOLUME_MUTE, data, allow_override=True) From 0f7c35859b81046a6dd3b8299f5ce1ce6f7dafb7 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Mon, 18 Sep 2017 21:44:26 +0200 Subject: [PATCH 222/277] Small improvement of KNX Covers (#9476) * Refactoring of Cover abstraction. Fixes https://github.com/XKNX/xknx/issues/57 and https://github.com/home-assistant/home-assistant/issues/9414 * Requested changes by pvizeli --- homeassistant/components/cover/knx.py | 58 ++++++++------------------- homeassistant/components/knx.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 18 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 296d8d36394..ae7bcfee17e 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -50,7 +50,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up cover(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -58,25 +58,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up covers for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXCover(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up cover for KNX platform configured within plattform.""" import xknx cover = xknx.devices.Cover( @@ -90,23 +90,20 @@ def async_add_devices_config(hass, config, add_devices): group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), group_address_position=config.get(CONF_POSITION_ADDRESS), travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), - travel_time_up=config.get(CONF_TRAVELLING_TIME_UP)) + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP), + invert_position=config.get(CONF_INVERT_POSITION), + invert_angle=config.get(CONF_INVERT_ANGLE)) - invert_position = config.get(CONF_INVERT_POSITION) - invert_angle = config.get(CONF_INVERT_ANGLE) hass.data[DATA_KNX].xknx.devices.add(cover) - add_devices([KNXCover(hass, cover, invert_position, invert_angle)]) + async_add_devices([KNXCover(hass, cover)]) class KNXCover(CoverDevice): """Representation of a KNX cover.""" - def __init__(self, hass, device, invert_position=False, - invert_angle=False): + def __init__(self, hass, device): """Initialize the cover.""" self.device = device - self.invert_position = invert_position - self.invert_angle = invert_angle self.hass = hass self.async_register_callbacks() @@ -144,9 +141,7 @@ class KNXCover(CoverDevice): @property def current_cover_position(self): """Return the current position of the cover.""" - return int(self.from_knx_position( - self.device.current_position(), - self.invert_position)) + return self.device.current_position() @property def is_closed(self): @@ -172,8 +167,7 @@ class KNXCover(CoverDevice): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] - knx_position = self.to_knx_position(position, self.invert_position) - yield from self.device.set_position(knx_position) + yield from self.device.set_position(position) self.start_auto_updater() @asyncio.coroutine @@ -187,17 +181,14 @@ class KNXCover(CoverDevice): """Return current tilt position of cover.""" if not self.device.supports_angle: return None - return int(self.from_knx_position( - self.device.angle, - self.invert_angle)) + return self.device.get_angle() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION in kwargs: - position = kwargs[ATTR_TILT_POSITION] - knx_position = self.to_knx_position(position, self.invert_angle) - yield from self.device.set_angle(knx_position) + tilt_position = kwargs[ATTR_TILT_POSITION] + yield from self.device.set_angle(tilt_position) def start_auto_updater(self): """Start the autoupdater to update HASS while cover is moving.""" @@ -220,20 +211,3 @@ class KNXCover(CoverDevice): self.stop_auto_updater() self.hass.add_job(self.device.auto_stop_if_necessary()) - - @staticmethod - def from_knx_position(raw, invert): - """Convert KNX position [0...255] to hass position [100...0].""" - position = round((raw/256)*100) - if not invert: - position = 100 - position - return position - - @staticmethod - def to_knx_position(value, invert): - """Convert hass position [100...0] to KNX position [0...255].""" - knx_position = round(value/100*255.4) - if not invert: - knx_position = 255-knx_position - print(value, " -> ", knx_position) - return knx_position diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index a5015ff9454..047620860b9 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -35,7 +35,7 @@ ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['xknx==0.7.13'] +REQUIREMENTS = ['xknx==0.7.14'] TUNNELING_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, diff --git a/requirements_all.txt b/requirements_all.txt index b58c1f846cf..8139567de9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1028,7 +1028,7 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.7.13 +xknx==0.7.14 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data From a05afd58e9d11c9c2ccbe19a8bb00633e3f56c7b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 18 Sep 2017 23:03:02 +0200 Subject: [PATCH 223/277] Upgrade async_timeout to 1.4.0 (#9488) --- 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 43de2a54dbb..ef34bd15319 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.3.0 +async_timeout==1.4.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 8139567de9c..e23c6aaaf55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.3.0 +async_timeout==1.4.0 chardet==3.0.4 astral==1.4 diff --git a/setup.py b/setup.py index 63f77820ca7..ce5b49d4232 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ REQUIRES = [ 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.2.5', - 'async_timeout==1.3.0', + 'async_timeout==1.4.0', 'chardet==3.0.4', 'astral==1.4', ] From e41b00fb4d24abbfff5bbcff9d538686a6185090 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Mon, 18 Sep 2017 21:53:03 -0700 Subject: [PATCH 224/277] Bump version of abodepy (#9491) --- homeassistant/components/abode.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 63c2fac48d1..00f70c719c5 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -21,7 +21,7 @@ from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.11.5'] +REQUIREMENTS = ['abodepy==0.11.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e23c6aaaf55..c7ef21d58ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -42,7 +42,7 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.11.5 +abodepy==0.11.6 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.3 From 252ee35d61d975da42d7d9121bdbc7f9c44024be Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 19 Sep 2017 10:03:40 +0200 Subject: [PATCH 225/277] Upgrade coinmarketcap to 4.1.1 (#9490) --- homeassistant/components/sensor/coinmarketcap.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/coinmarketcap.py b/homeassistant/components/sensor/coinmarketcap.py index 332cfe7ba15..616b30abf2b 100644 --- a/homeassistant/components/sensor/coinmarketcap.py +++ b/homeassistant/components/sensor/coinmarketcap.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_CURRENCY from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['coinmarketcap==3.0.1'] +REQUIREMENTS = ['coinmarketcap==4.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c7ef21d58ab..4baa2b5c281 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -140,7 +140,7 @@ buienradar==0.9 ciscosparkapi==0.4.2 # homeassistant.components.sensor.coinmarketcap -coinmarketcap==3.0.1 +coinmarketcap==4.1.1 # homeassistant.scripts.check_config colorlog==3.0.1 From 8ea7e4bb55db98e2b7a089cc1f540d679eaa237c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 19 Sep 2017 10:04:11 +0200 Subject: [PATCH 226/277] Upgrade blockchain to 1.4.0 (#9489) --- homeassistant/components/sensor/bitcoin.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 4c5cbc248dd..31c6c1809b3 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -15,7 +15,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['blockchain==1.3.3'] +REQUIREMENTS = ['blockchain==1.4.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 4baa2b5c281..7f9b8a917c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -114,7 +114,7 @@ blinkstick==1.1.8 # blinkt==0.1.0 # homeassistant.components.sensor.bitcoin -blockchain==1.3.3 +blockchain==1.4.0 # homeassistant.components.light.decora # bluepy==1.1.1 From dcaa5fe4439a1eec53460f31c1f6e1af007fc7b8 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Tue, 19 Sep 2017 10:09:47 +0200 Subject: [PATCH 227/277] Solve Recorder component failing when using Axis component (#9293) * Bump Axis requirement to v10 Fix issues related to non JSON serializable items and recorder component (8297) Add support to configure HTTP port (8403) * Changed local port definition to CONF_PORT * On request config is now sent to the camera platform as well, and in order better explain what is what the old internal config is now device_config and hass own config is the only one referenced as config * Missed to add device_config to setup in discovered device * Bump to V12 that has got a dependency fix * Update requirements_all * Add port configuration to automatically discovered devices Allow setup to pass without Axis being configured in configuration.yaml --- homeassistant/components/axis.py | 66 +++++++++++++++---------- homeassistant/components/camera/axis.py | 26 ++++++---- requirements_all.txt | 2 +- 3 files changed, 58 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index eaf85937658..aee8dbc415b 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, CONF_HOST, CONF_INCLUDE, CONF_NAME, - CONF_PASSWORD, CONF_TRIGGER_TIME, + CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.components.discovery import SERVICE_AXIS from homeassistant.helpers import config_validation as cv @@ -23,7 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['axis==8'] +REQUIREMENTS = ['axis==12'] _LOGGER = logging.getLogger(__name__) @@ -51,6 +51,7 @@ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, + vol.Optional(CONF_PORT, default=80): cv.positive_int, vol.Optional(ATTR_LOCATION, default=''): cv.string, }) @@ -76,7 +77,7 @@ SERVICE_SCHEMA = vol.Schema({ }) -def request_configuration(hass, name, host, serialnumber): +def request_configuration(hass, config, name, host, serialnumber): """Request configuration steps from the user.""" configurator = hass.components.configurator @@ -91,15 +92,15 @@ def request_configuration(hass, name, host, serialnumber): if CONF_NAME not in callback_data: callback_data[CONF_NAME] = name try: - config = DEVICE_SCHEMA(callback_data) + device_config = DEVICE_SCHEMA(callback_data) except vol.Invalid: configurator.notify_errors(request_id, "Bad input, please check spelling.") return False - if setup_device(hass, config): + if setup_device(hass, config, device_config): config_file = _read_config(hass) - config_file[serialnumber] = dict(config) + config_file[serialnumber] = dict(device_config) del config_file[serialnumber]['hass'] _write_config(hass, config_file) configurator.request_done(request_id) @@ -132,6 +133,9 @@ def request_configuration(hass, name, host, serialnumber): {'id': ATTR_LOCATION, 'name': "Physical location of device (optional)", 'type': 'text'}, + {'id': CONF_PORT, + 'name': "HTTP port (default=80)", + 'type': 'number'}, {'id': CONF_TRIGGER_TIME, 'name': "Sensor update interval (optional)", 'type': 'number'}, @@ -139,7 +143,7 @@ def request_configuration(hass, name, host, serialnumber): ) -def setup(hass, base_config): +def setup(hass, config): """Common setup for Axis devices.""" def _shutdown(call): # pylint: disable=unused-argument """Stop the metadatastream on shutdown.""" @@ -160,16 +164,17 @@ def setup(hass, base_config): if serialnumber in config_file: # Device config saved to file try: - config = DEVICE_SCHEMA(config_file[serialnumber]) - config[CONF_HOST] = host + device_config = DEVICE_SCHEMA(config_file[serialnumber]) + device_config[CONF_HOST] = host except vol.Invalid as err: _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) return False - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn\'t set up %s", + device_config[CONF_NAME]) else: # New device, create configuration request for UI - request_configuration(hass, name, host, serialnumber) + request_configuration(hass, config, name, host, serialnumber) else: # Device already registered, but on a different IP device = AXIS_DEVICES[serialnumber] @@ -181,13 +186,13 @@ def setup(hass, base_config): # Register discovery service discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) - if DOMAIN in base_config: - for device in base_config[DOMAIN]: - config = base_config[DOMAIN][device] - if CONF_NAME not in config: - config[CONF_NAME] = device - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + if DOMAIN in config: + for device in config[DOMAIN]: + device_config = config[DOMAIN][device] + if CONF_NAME not in device_config: + device_config[CONF_NAME] = device + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn\'t set up %s", device_config[CONF_NAME]) # Services to communicate with device. descriptions = load_yaml_config_file( @@ -215,20 +220,20 @@ def setup(hass, base_config): return True -def setup_device(hass, config): +def setup_device(hass, config, device_config): """Set up device.""" from axis import AxisDevice - config['hass'] = hass - device = AxisDevice(config) # Initialize device + device_config['hass'] = hass + device = AxisDevice(device_config) # Initialize device enable_metadatastream = False if device.serial_number is None: # If there is no serial number a connection could not be made - _LOGGER.error("Couldn\'t connect to %s", config[CONF_HOST]) + _LOGGER.error("Couldn\'t connect to %s", device_config[CONF_HOST]) return False - for component in config[CONF_INCLUDE]: + for component in device_config[CONF_INCLUDE]: if component in EVENT_TYPES: # Sensors are created by device calling event_initialized # when receiving initialize messages on metadatastream @@ -236,7 +241,18 @@ def setup_device(hass, config): if not enable_metadatastream: enable_metadatastream = True else: - discovery.load_platform(hass, component, DOMAIN, config) + camera_config = { + CONF_HOST: device_config[CONF_HOST], + CONF_NAME: device_config[CONF_NAME], + CONF_PORT: device_config[CONF_PORT], + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD] + } + discovery.load_platform(hass, + component, + DOMAIN, + camera_config, + config) if enable_metadatastream: device.initialize_new_event = event_initialized diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index b0295b9ee34..ee8ccce1a9c 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/camera.axis/ import logging from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.components.camera.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) @@ -19,38 +19,44 @@ DOMAIN = 'axis' DEPENDENCIES = [DOMAIN] -def _get_image_url(host, mode): +def _get_image_url(host, port, mode): if mode == 'mjpeg': - return 'http://{}/axis-cgi/mjpg/video.cgi'.format(host) + return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port) elif mode == 'single': - return 'http://{}/axis-cgi/jpg/image.cgi'.format(host) + return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Axis camera.""" - config = { + camera_config = { CONF_NAME: discovery_info[CONF_NAME], CONF_USERNAME: discovery_info[CONF_USERNAME], CONF_PASSWORD: discovery_info[CONF_PASSWORD], - CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], 'mjpeg'), + CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], + str(discovery_info[CONF_PORT]), + 'mjpeg'), CONF_STILL_IMAGE_URL: _get_image_url(discovery_info[CONF_HOST], + str(discovery_info[CONF_PORT]), 'single'), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } - add_devices([AxisCamera(hass, config)]) + add_devices([AxisCamera(hass, + camera_config, + str(discovery_info[CONF_PORT]))]) class AxisCamera(MjpegCamera): """AxisCamera class.""" - def __init__(self, hass, config): + def __init__(self, hass, config, port): """Initialize Axis Communications camera component.""" super().__init__(hass, config) + self.port = port async_dispatcher_connect(hass, DOMAIN + '_' + config[CONF_NAME] + '_new_ip', self._new_ip) def _new_ip(self, host): """Set new IP for video stream.""" - self._mjpeg_url = _get_image_url(host, 'mjpeg') - self._still_image_url = _get_image_url(host, 'mjpeg') + self._mjpeg_url = _get_image_url(host, self.port, 'mjpeg') + self._still_image_url = _get_image_url(host, self.port, 'single') diff --git a/requirements_all.txt b/requirements_all.txt index 7f9b8a917c6..ef2d717b5e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -85,7 +85,7 @@ asterisk_mbox==0.4.0 # avion==0.7 # homeassistant.components.axis -axis==8 +axis==12 # homeassistant.components.sensor.modem_callerid basicmodem==0.7 From 185ada2354e538faa7604bdc8a00403d2952e165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 19 Sep 2017 05:36:59 -0400 Subject: [PATCH 228/277] switch to pypi for xiaomi gw (#9498) --- homeassistant/components/xiaomi_aqara.py | 3 +-- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 53d3d3e7ad3..6512a34fcef 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -8,8 +8,7 @@ from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC) -REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' - '0.4.0.zip#PyXiaomiGateway==0.4.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.4.2'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' diff --git a/requirements_all.txt b/requirements_all.txt index ef2d717b5e7..282254ef776 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -32,6 +32,9 @@ PyMVGLive==1.1.4 # homeassistant.components.arduino PyMata==2.14 +# homeassistant.components.xiaomi_aqara +PyXiaomiGateway==0.4.2 + # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 @@ -301,9 +304,6 @@ holidays==0.8.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a -# homeassistant.components.xiaomi_aqara -https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.4.0.zip#PyXiaomiGateway==0.4.0 - # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 From a5a970709f02b5ef0be96af434371806863f81c7 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Tue, 19 Sep 2017 17:06:52 +0200 Subject: [PATCH 229/277] renamed add_devices to async_add_devices according to hass naming scheme (#9485) * renamed add_devices to async_add_devices according to hass naming scheme * replaced some occurencies of async_add_entites to async_add_devices * fixed unit test * fixed unit test --- .../components/alarm_control_panel/spc.py | 14 +++++++------- homeassistant/components/binary_sensor/knx.py | 14 +++++++------- homeassistant/components/binary_sensor/spc.py | 4 ++-- homeassistant/components/climate/knx.py | 14 +++++++------- .../frontend/www_static/home-assistant-polymer | 2 +- homeassistant/components/light/knx.py | 14 +++++++------- homeassistant/components/sensor/citybikes.py | 8 ++++---- homeassistant/components/sensor/knx.py | 14 +++++++------- homeassistant/components/sensor/worxlandroid.py | 10 ++++------ homeassistant/components/switch/knx.py | 14 +++++++------- tests/components/alarm_control_panel/test_spc.py | 2 +- tests/components/binary_sensor/test_spc.py | 2 +- 12 files changed, 55 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index de4d5098b41..1682ef2ae02 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -27,20 +27,20 @@ def _get_alarm_state(spc_mode): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the SPC alarm control panel platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_AREAS] is None): return - entities = [SpcAlarm(hass=hass, - area_id=area['id'], - name=area['name'], - state=_get_alarm_state(area['mode'])) - for area in discovery_info[ATTR_DISCOVER_AREAS]] + devices = [SpcAlarm(hass=hass, + area_id=area['id'], + name=area['name'], + state=_get_alarm_state(area['mode'])) + for area in discovery_info[ATTR_DISCOVER_AREAS]] - async_add_entities(entities) + async_add_devices(devices) class SpcAlarm(alarm.AlarmControlPanel): diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 2b11c3fe172..406f60f99bb 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -53,7 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up binary sensor(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -61,25 +61,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up binary sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXBinarySensor(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up binary senor for KNX platform configured within plattform.""" name = config.get(CONF_NAME) import xknx @@ -101,7 +101,7 @@ def async_add_devices_config(hass, config, add_devices): entity.automations.append(KNXAutomation( hass=hass, device=binary_sensor, hook=hook, action=action, counter=counter)) - add_devices([entity]) + async_add_devices([entity]) class KNXBinarySensor(BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index 8023e1cf4b3..af3669c2b15 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -41,14 +41,14 @@ def _create_sensor(hass, zone): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Initialize the platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None): return - async_add_entities( + async_add_devices( _create_sensor(hass, zone) for zone in discovery_info[ATTR_DISCOVER_DEVICES] if _get_device_class(zone['type'])) diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 688ded5e7c4..9bf44c9b9ab 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up climate(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -52,25 +52,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up climates for KNX platform configured within plattform.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXClimate(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up climate for KNX platform configured within plattform.""" import xknx climate = xknx.devices.Climate( @@ -97,7 +97,7 @@ def async_add_devices_config(hass, config, add_devices): group_address_operation_mode_comfort=config.get( CONF_OPERATION_MODE_COMFORT_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(climate) - add_devices([KNXClimate(hass, climate)]) + async_add_devices([KNXClimate(hass, climate)]) class KNXClimate(ClimateDevice): diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 73289bca6d4..07d5d6e8a92 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 73289bca6d4e326de4484e991019e10f69a351ed +Subproject commit 07d5d6e8a9205f77f83f68615695bcbc73cf83e3 diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 62261944feb..3688cafdd25 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -32,7 +32,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up light(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -40,25 +40,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up lights for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXLight(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up light for KNX platform configured within plattform.""" import xknx light = xknx.devices.Light( @@ -70,7 +70,7 @@ def async_add_devices_config(hass, config, add_devices): group_address_brightness_state=config.get( CONF_BRIGHTNESS_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(light) - add_devices([KNXLight(hass, light)]) + async_add_devices([KNXLight(hass, light)]) class KNXLight(Light): diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index 32b82b15631..a7bf7533e32 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -129,7 +129,7 @@ def async_citybikes_request(hass, uri, schema): # pylint: disable=unused-argument @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the CityBikes platform.""" if DOMAIN not in hass.data: @@ -159,7 +159,7 @@ def async_setup_platform(hass, config, async_add_entities, yield from network.ready.wait() - entities = [] + devices = [] for station in network.stations: dist = location.distance(latitude, longitude, station[ATTR_LATITUDE], @@ -169,9 +169,9 @@ def async_setup_platform(hass, config, async_add_entities, if radius > dist or stations_list.intersection((station_id, station_uid)): - entities.append(CityBikesStation(network, station_id, name)) + devices.append(CityBikesStation(network, station_id, name)) - async_add_entities(entities, True) + async_add_devices(devices, True) class CityBikesNetwork: diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 60f11d76e79..7abc986bdd7 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up sensor(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -36,25 +36,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXSensor(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up sensor for KNX platform configured within plattform.""" import xknx sensor = xknx.devices.Sensor( @@ -63,7 +63,7 @@ def async_add_devices_config(hass, config, add_devices): group_address=config.get(CONF_ADDRESS), value_type=config.get(CONF_TYPE)) hass.data[DATA_KNX].xknx.devices.add(sensor) - add_devices([KNXSensor(hass, sensor)]) + async_add_devices([KNXSensor(hass, sensor)]) class KNXSensor(Entity): diff --git a/homeassistant/components/sensor/worxlandroid.py b/homeassistant/components/sensor/worxlandroid.py index 324771c163c..ddf506bf4eb 100644 --- a/homeassistant/components/sensor/worxlandroid.py +++ b/homeassistant/components/sensor/worxlandroid.py @@ -51,11 +51,11 @@ ERROR_STATE = [ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Worx Landroid sensors.""" for typ in ('battery', 'state'): - async_add_entities([WorxLandroidSensor(typ, config)]) + async_add_devices([WorxLandroidSensor(typ, config)]) class WorxLandroidSensor(Entity): @@ -86,8 +86,7 @@ class WorxLandroidSensor(Entity): """Return the unit of measurement of the sensor.""" if self.sensor == 'battery': return '%' - else: - return None + return None @asyncio.coroutine def async_update(self): @@ -159,7 +158,6 @@ class WorxLandroidSensor(Entity): return 'charging-complete' elif state_obj[15] == 1: return 'going-home' - else: - return 'mowing' + return 'mowing' return state diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index 90b04239086..b340bf5f43a 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -27,7 +27,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up switch(es) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -35,25 +35,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up switches for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXSwitch(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up switch for KNX platform configured within plattform.""" import xknx switch = xknx.devices.Switch( @@ -62,7 +62,7 @@ def async_add_devices_config(hass, config, add_devices): group_address=config.get(CONF_ADDRESS), group_address_state=config.get(CONF_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(switch) - add_devices([KNXSwitch(hass, switch)]) + async_add_devices([KNXSwitch(hass, switch)]) class KNXSwitch(SwitchDevice): diff --git a/tests/components/alarm_control_panel/test_spc.py b/tests/components/alarm_control_panel/test_spc.py index 9fcfbfd56d2..504b4e9237c 100644 --- a/tests/components/alarm_control_panel/test_spc.py +++ b/tests/components/alarm_control_panel/test_spc.py @@ -54,7 +54,7 @@ def test_setup_platform(hass): yield from spc.async_setup_platform(hass=hass, config={}, - async_add_entities=add_entities, + async_add_devices=add_entities, discovery_info=areas) assert len(added_entities) == 2 diff --git a/tests/components/binary_sensor/test_spc.py b/tests/components/binary_sensor/test_spc.py index 2acd093dc1f..5004ccd3210 100644 --- a/tests/components/binary_sensor/test_spc.py +++ b/tests/components/binary_sensor/test_spc.py @@ -54,7 +54,7 @@ def test_setup_platform(hass): yield from spc.async_setup_platform(hass=hass, config={}, - async_add_entities=add_entities, + async_add_devices=add_entities, discovery_info=zones) assert len(added_entities) == 3 From 1bbaa009764d21f4eeacc0f1925db96a24438b48 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 19 Sep 2017 19:51:15 +0200 Subject: [PATCH 230/277] Revert "renamed add_devices to async_add_devices according to hass naming scheme (#9485)" (#9503) This reverts commit a5a970709f02b5ef0be96af434371806863f81c7. --- .../components/alarm_control_panel/spc.py | 14 +++++++------- homeassistant/components/binary_sensor/knx.py | 14 +++++++------- homeassistant/components/binary_sensor/spc.py | 4 ++-- homeassistant/components/climate/knx.py | 14 +++++++------- .../frontend/www_static/home-assistant-polymer | 2 +- homeassistant/components/light/knx.py | 14 +++++++------- homeassistant/components/sensor/citybikes.py | 8 ++++---- homeassistant/components/sensor/knx.py | 14 +++++++------- homeassistant/components/sensor/worxlandroid.py | 10 ++++++---- homeassistant/components/switch/knx.py | 14 +++++++------- tests/components/alarm_control_panel/test_spc.py | 2 +- tests/components/binary_sensor/test_spc.py | 2 +- 12 files changed, 57 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index 1682ef2ae02..de4d5098b41 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -27,20 +27,20 @@ def _get_alarm_state(spc_mode): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the SPC alarm control panel platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_AREAS] is None): return - devices = [SpcAlarm(hass=hass, - area_id=area['id'], - name=area['name'], - state=_get_alarm_state(area['mode'])) - for area in discovery_info[ATTR_DISCOVER_AREAS]] + entities = [SpcAlarm(hass=hass, + area_id=area['id'], + name=area['name'], + state=_get_alarm_state(area['mode'])) + for area in discovery_info[ATTR_DISCOVER_AREAS]] - async_add_devices(devices) + async_add_entities(entities) class SpcAlarm(alarm.AlarmControlPanel): diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 406f60f99bb..2b11c3fe172 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -53,7 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, add_devices, discovery_info=None): """Set up binary sensor(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -61,25 +61,25 @@ def async_setup_platform(hass, config, async_add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, async_add_devices) + async_add_devices_discovery(hass, discovery_info, add_devices) else: - async_add_devices_config(hass, config, async_add_devices) + async_add_devices_config(hass, config, add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, async_add_devices): +def async_add_devices_discovery(hass, discovery_info, add_devices): """Set up binary sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXBinarySensor(hass, device)) - async_add_devices(entities) + add_devices(entities) @callback -def async_add_devices_config(hass, config, async_add_devices): +def async_add_devices_config(hass, config, add_devices): """Set up binary senor for KNX platform configured within plattform.""" name = config.get(CONF_NAME) import xknx @@ -101,7 +101,7 @@ def async_add_devices_config(hass, config, async_add_devices): entity.automations.append(KNXAutomation( hass=hass, device=binary_sensor, hook=hook, action=action, counter=counter)) - async_add_devices([entity]) + add_devices([entity]) class KNXBinarySensor(BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index af3669c2b15..8023e1cf4b3 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -41,14 +41,14 @@ def _create_sensor(hass, zone): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Initialize the platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None): return - async_add_devices( + async_add_entities( _create_sensor(hass, zone) for zone in discovery_info[ATTR_DISCOVER_DEVICES] if _get_device_class(zone['type'])) diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 9bf44c9b9ab..688ded5e7c4 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, add_devices, discovery_info=None): """Set up climate(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -52,25 +52,25 @@ def async_setup_platform(hass, config, async_add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, async_add_devices) + async_add_devices_discovery(hass, discovery_info, add_devices) else: - async_add_devices_config(hass, config, async_add_devices) + async_add_devices_config(hass, config, add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, async_add_devices): +def async_add_devices_discovery(hass, discovery_info, add_devices): """Set up climates for KNX platform configured within plattform.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXClimate(hass, device)) - async_add_devices(entities) + add_devices(entities) @callback -def async_add_devices_config(hass, config, async_add_devices): +def async_add_devices_config(hass, config, add_devices): """Set up climate for KNX platform configured within plattform.""" import xknx climate = xknx.devices.Climate( @@ -97,7 +97,7 @@ def async_add_devices_config(hass, config, async_add_devices): group_address_operation_mode_comfort=config.get( CONF_OPERATION_MODE_COMFORT_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(climate) - async_add_devices([KNXClimate(hass, climate)]) + add_devices([KNXClimate(hass, climate)]) class KNXClimate(ClimateDevice): diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 07d5d6e8a92..73289bca6d4 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 07d5d6e8a9205f77f83f68615695bcbc73cf83e3 +Subproject commit 73289bca6d4e326de4484e991019e10f69a351ed diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 3688cafdd25..62261944feb 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -32,7 +32,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, add_devices, discovery_info=None): """Set up light(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -40,25 +40,25 @@ def async_setup_platform(hass, config, async_add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, async_add_devices) + async_add_devices_discovery(hass, discovery_info, add_devices) else: - async_add_devices_config(hass, config, async_add_devices) + async_add_devices_config(hass, config, add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, async_add_devices): +def async_add_devices_discovery(hass, discovery_info, add_devices): """Set up lights for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXLight(hass, device)) - async_add_devices(entities) + add_devices(entities) @callback -def async_add_devices_config(hass, config, async_add_devices): +def async_add_devices_config(hass, config, add_devices): """Set up light for KNX platform configured within plattform.""" import xknx light = xknx.devices.Light( @@ -70,7 +70,7 @@ def async_add_devices_config(hass, config, async_add_devices): group_address_brightness_state=config.get( CONF_BRIGHTNESS_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(light) - async_add_devices([KNXLight(hass, light)]) + add_devices([KNXLight(hass, light)]) class KNXLight(Light): diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index a7bf7533e32..32b82b15631 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -129,7 +129,7 @@ def async_citybikes_request(hass, uri, schema): # pylint: disable=unused-argument @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the CityBikes platform.""" if DOMAIN not in hass.data: @@ -159,7 +159,7 @@ def async_setup_platform(hass, config, async_add_devices, yield from network.ready.wait() - devices = [] + entities = [] for station in network.stations: dist = location.distance(latitude, longitude, station[ATTR_LATITUDE], @@ -169,9 +169,9 @@ def async_setup_platform(hass, config, async_add_devices, if radius > dist or stations_list.intersection((station_id, station_uid)): - devices.append(CityBikesStation(network, station_id, name)) + entities.append(CityBikesStation(network, station_id, name)) - async_add_devices(devices, True) + async_add_entities(entities, True) class CityBikesNetwork: diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 7abc986bdd7..60f11d76e79 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, add_devices, discovery_info=None): """Set up sensor(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -36,25 +36,25 @@ def async_setup_platform(hass, config, async_add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, async_add_devices) + async_add_devices_discovery(hass, discovery_info, add_devices) else: - async_add_devices_config(hass, config, async_add_devices) + async_add_devices_config(hass, config, add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, async_add_devices): +def async_add_devices_discovery(hass, discovery_info, add_devices): """Set up sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXSensor(hass, device)) - async_add_devices(entities) + add_devices(entities) @callback -def async_add_devices_config(hass, config, async_add_devices): +def async_add_devices_config(hass, config, add_devices): """Set up sensor for KNX platform configured within plattform.""" import xknx sensor = xknx.devices.Sensor( @@ -63,7 +63,7 @@ def async_add_devices_config(hass, config, async_add_devices): group_address=config.get(CONF_ADDRESS), value_type=config.get(CONF_TYPE)) hass.data[DATA_KNX].xknx.devices.add(sensor) - async_add_devices([KNXSensor(hass, sensor)]) + add_devices([KNXSensor(hass, sensor)]) class KNXSensor(Entity): diff --git a/homeassistant/components/sensor/worxlandroid.py b/homeassistant/components/sensor/worxlandroid.py index ddf506bf4eb..324771c163c 100644 --- a/homeassistant/components/sensor/worxlandroid.py +++ b/homeassistant/components/sensor/worxlandroid.py @@ -51,11 +51,11 @@ ERROR_STATE = [ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Worx Landroid sensors.""" for typ in ('battery', 'state'): - async_add_devices([WorxLandroidSensor(typ, config)]) + async_add_entities([WorxLandroidSensor(typ, config)]) class WorxLandroidSensor(Entity): @@ -86,7 +86,8 @@ class WorxLandroidSensor(Entity): """Return the unit of measurement of the sensor.""" if self.sensor == 'battery': return '%' - return None + else: + return None @asyncio.coroutine def async_update(self): @@ -158,6 +159,7 @@ class WorxLandroidSensor(Entity): return 'charging-complete' elif state_obj[15] == 1: return 'going-home' - return 'mowing' + else: + return 'mowing' return state diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index b340bf5f43a..90b04239086 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -27,7 +27,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, +def async_setup_platform(hass, config, add_devices, discovery_info=None): """Set up switch(es) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -35,25 +35,25 @@ def async_setup_platform(hass, config, async_add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, async_add_devices) + async_add_devices_discovery(hass, discovery_info, add_devices) else: - async_add_devices_config(hass, config, async_add_devices) + async_add_devices_config(hass, config, add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, async_add_devices): +def async_add_devices_discovery(hass, discovery_info, add_devices): """Set up switches for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXSwitch(hass, device)) - async_add_devices(entities) + add_devices(entities) @callback -def async_add_devices_config(hass, config, async_add_devices): +def async_add_devices_config(hass, config, add_devices): """Set up switch for KNX platform configured within plattform.""" import xknx switch = xknx.devices.Switch( @@ -62,7 +62,7 @@ def async_add_devices_config(hass, config, async_add_devices): group_address=config.get(CONF_ADDRESS), group_address_state=config.get(CONF_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(switch) - async_add_devices([KNXSwitch(hass, switch)]) + add_devices([KNXSwitch(hass, switch)]) class KNXSwitch(SwitchDevice): diff --git a/tests/components/alarm_control_panel/test_spc.py b/tests/components/alarm_control_panel/test_spc.py index 504b4e9237c..9fcfbfd56d2 100644 --- a/tests/components/alarm_control_panel/test_spc.py +++ b/tests/components/alarm_control_panel/test_spc.py @@ -54,7 +54,7 @@ def test_setup_platform(hass): yield from spc.async_setup_platform(hass=hass, config={}, - async_add_devices=add_entities, + async_add_entities=add_entities, discovery_info=areas) assert len(added_entities) == 2 diff --git a/tests/components/binary_sensor/test_spc.py b/tests/components/binary_sensor/test_spc.py index 5004ccd3210..2acd093dc1f 100644 --- a/tests/components/binary_sensor/test_spc.py +++ b/tests/components/binary_sensor/test_spc.py @@ -54,7 +54,7 @@ def test_setup_platform(hass): yield from spc.async_setup_platform(hass=hass, config={}, - async_add_devices=add_entities, + async_add_entities=add_entities, discovery_info=zones) assert len(added_entities) == 3 From 3dbf95108670716ef72c741bdf70a7d21fe3269d Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 19 Sep 2017 22:27:00 +0200 Subject: [PATCH 231/277] LIFX: fix multi-zone color restore after effects (#9492) The aiolifx 0.6.0 release fixes an issue where an effect color could remain set after stopping the effect. This only affected multi-zone lights (i.e. LIFX Z) and only if the effect was stopped by setting the light brightness or the color (but not both). The aiolifx 0.6.0 release also defaults end_index to start_index+7, so we can remove that argument. Finally, aiolifx_effects 0.1.2 adds support for aiolifx 0.6.0. --- homeassistant/components/light/lifx.py | 5 ++--- requirements_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 6b57a1c5146..93412710987 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -33,7 +33,7 @@ import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.5.4', 'aiolifx_effects==0.1.1'] +REQUIREMENTS = ['aiolifx==0.6.0', 'aiolifx_effects==0.1.2'] UDP_BROADCAST_PORT = 56700 @@ -684,8 +684,7 @@ class LIFXStrip(LIFXColor): # Each get_color_zones can update 8 zones at once resp = yield from AwaitAioLIFX().wait(partial( self.device.get_color_zones, - start_index=zone, - end_index=zone+7)) + start_index=zone)) if resp: zone += 8 top = resp.count diff --git a/requirements_all.txt b/requirements_all.txt index 282254ef776..0cdcff9150d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -58,10 +58,10 @@ aiodns==1.1.1 aiohttp_cors==0.5.3 # homeassistant.components.light.lifx -aiolifx==0.5.4 +aiolifx==0.6.0 # homeassistant.components.light.lifx -aiolifx_effects==0.1.1 +aiolifx_effects==0.1.2 # homeassistant.components.scene.hunterdouglas_powerview aiopvapi==1.4 From a5155a2609bb13621e0494042c2968ed78f0bdcb Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Wed, 20 Sep 2017 07:15:20 +0200 Subject: [PATCH 232/277] renamed add_devices to async_add_devices according to hass naming scheme (second try after failed #9485) (#9505) --- .../components/alarm_control_panel/spc.py | 14 +++++++------- homeassistant/components/binary_sensor/knx.py | 14 +++++++------- homeassistant/components/binary_sensor/spc.py | 4 ++-- homeassistant/components/climate/knx.py | 14 +++++++------- homeassistant/components/light/knx.py | 14 +++++++------- homeassistant/components/sensor/citybikes.py | 8 ++++---- homeassistant/components/sensor/knx.py | 14 +++++++------- homeassistant/components/sensor/worxlandroid.py | 10 ++++------ homeassistant/components/switch/knx.py | 14 +++++++------- tests/components/alarm_control_panel/test_spc.py | 2 +- tests/components/binary_sensor/test_spc.py | 2 +- 11 files changed, 54 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index de4d5098b41..1682ef2ae02 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -27,20 +27,20 @@ def _get_alarm_state(spc_mode): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the SPC alarm control panel platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_AREAS] is None): return - entities = [SpcAlarm(hass=hass, - area_id=area['id'], - name=area['name'], - state=_get_alarm_state(area['mode'])) - for area in discovery_info[ATTR_DISCOVER_AREAS]] + devices = [SpcAlarm(hass=hass, + area_id=area['id'], + name=area['name'], + state=_get_alarm_state(area['mode'])) + for area in discovery_info[ATTR_DISCOVER_AREAS]] - async_add_entities(entities) + async_add_devices(devices) class SpcAlarm(alarm.AlarmControlPanel): diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 2b11c3fe172..406f60f99bb 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -53,7 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up binary sensor(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -61,25 +61,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up binary sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXBinarySensor(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up binary senor for KNX platform configured within plattform.""" name = config.get(CONF_NAME) import xknx @@ -101,7 +101,7 @@ def async_add_devices_config(hass, config, add_devices): entity.automations.append(KNXAutomation( hass=hass, device=binary_sensor, hook=hook, action=action, counter=counter)) - add_devices([entity]) + async_add_devices([entity]) class KNXBinarySensor(BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index 8023e1cf4b3..af3669c2b15 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -41,14 +41,14 @@ def _create_sensor(hass, zone): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Initialize the platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None): return - async_add_entities( + async_add_devices( _create_sensor(hass, zone) for zone in discovery_info[ATTR_DISCOVER_DEVICES] if _get_device_class(zone['type'])) diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 688ded5e7c4..9bf44c9b9ab 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up climate(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -52,25 +52,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up climates for KNX platform configured within plattform.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXClimate(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up climate for KNX platform configured within plattform.""" import xknx climate = xknx.devices.Climate( @@ -97,7 +97,7 @@ def async_add_devices_config(hass, config, add_devices): group_address_operation_mode_comfort=config.get( CONF_OPERATION_MODE_COMFORT_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(climate) - add_devices([KNXClimate(hass, climate)]) + async_add_devices([KNXClimate(hass, climate)]) class KNXClimate(ClimateDevice): diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 62261944feb..3688cafdd25 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -32,7 +32,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up light(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -40,25 +40,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up lights for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXLight(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up light for KNX platform configured within plattform.""" import xknx light = xknx.devices.Light( @@ -70,7 +70,7 @@ def async_add_devices_config(hass, config, add_devices): group_address_brightness_state=config.get( CONF_BRIGHTNESS_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(light) - add_devices([KNXLight(hass, light)]) + async_add_devices([KNXLight(hass, light)]) class KNXLight(Light): diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index 32b82b15631..a7bf7533e32 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -129,7 +129,7 @@ def async_citybikes_request(hass, uri, schema): # pylint: disable=unused-argument @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the CityBikes platform.""" if DOMAIN not in hass.data: @@ -159,7 +159,7 @@ def async_setup_platform(hass, config, async_add_entities, yield from network.ready.wait() - entities = [] + devices = [] for station in network.stations: dist = location.distance(latitude, longitude, station[ATTR_LATITUDE], @@ -169,9 +169,9 @@ def async_setup_platform(hass, config, async_add_entities, if radius > dist or stations_list.intersection((station_id, station_uid)): - entities.append(CityBikesStation(network, station_id, name)) + devices.append(CityBikesStation(network, station_id, name)) - async_add_entities(entities, True) + async_add_devices(devices, True) class CityBikesNetwork: diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 60f11d76e79..7abc986bdd7 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up sensor(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -36,25 +36,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXSensor(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up sensor for KNX platform configured within plattform.""" import xknx sensor = xknx.devices.Sensor( @@ -63,7 +63,7 @@ def async_add_devices_config(hass, config, add_devices): group_address=config.get(CONF_ADDRESS), value_type=config.get(CONF_TYPE)) hass.data[DATA_KNX].xknx.devices.add(sensor) - add_devices([KNXSensor(hass, sensor)]) + async_add_devices([KNXSensor(hass, sensor)]) class KNXSensor(Entity): diff --git a/homeassistant/components/sensor/worxlandroid.py b/homeassistant/components/sensor/worxlandroid.py index 324771c163c..ddf506bf4eb 100644 --- a/homeassistant/components/sensor/worxlandroid.py +++ b/homeassistant/components/sensor/worxlandroid.py @@ -51,11 +51,11 @@ ERROR_STATE = [ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Worx Landroid sensors.""" for typ in ('battery', 'state'): - async_add_entities([WorxLandroidSensor(typ, config)]) + async_add_devices([WorxLandroidSensor(typ, config)]) class WorxLandroidSensor(Entity): @@ -86,8 +86,7 @@ class WorxLandroidSensor(Entity): """Return the unit of measurement of the sensor.""" if self.sensor == 'battery': return '%' - else: - return None + return None @asyncio.coroutine def async_update(self): @@ -159,7 +158,6 @@ class WorxLandroidSensor(Entity): return 'charging-complete' elif state_obj[15] == 1: return 'going-home' - else: - return 'mowing' + return 'mowing' return state diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index 90b04239086..b340bf5f43a 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -27,7 +27,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up switch(es) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -35,25 +35,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up switches for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXSwitch(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up switch for KNX platform configured within plattform.""" import xknx switch = xknx.devices.Switch( @@ -62,7 +62,7 @@ def async_add_devices_config(hass, config, add_devices): group_address=config.get(CONF_ADDRESS), group_address_state=config.get(CONF_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(switch) - add_devices([KNXSwitch(hass, switch)]) + async_add_devices([KNXSwitch(hass, switch)]) class KNXSwitch(SwitchDevice): diff --git a/tests/components/alarm_control_panel/test_spc.py b/tests/components/alarm_control_panel/test_spc.py index 9fcfbfd56d2..504b4e9237c 100644 --- a/tests/components/alarm_control_panel/test_spc.py +++ b/tests/components/alarm_control_panel/test_spc.py @@ -54,7 +54,7 @@ def test_setup_platform(hass): yield from spc.async_setup_platform(hass=hass, config={}, - async_add_entities=add_entities, + async_add_devices=add_entities, discovery_info=areas) assert len(added_entities) == 2 diff --git a/tests/components/binary_sensor/test_spc.py b/tests/components/binary_sensor/test_spc.py index 2acd093dc1f..5004ccd3210 100644 --- a/tests/components/binary_sensor/test_spc.py +++ b/tests/components/binary_sensor/test_spc.py @@ -54,7 +54,7 @@ def test_setup_platform(hass): yield from spc.async_setup_platform(hass=hass, config={}, - async_add_entities=add_entities, + async_add_devices=add_entities, discovery_info=zones) assert len(added_entities) == 3 From 7314ec7a426261276b4c2643acc9817bb59f485b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 20 Sep 2017 11:43:25 +0200 Subject: [PATCH 233/277] Xiaomi pycryptodome (#9511) * Switch to use pycryptodome for xiaomi_gw --- homeassistant/components/xiaomi_aqara.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 6512a34fcef..3e22746a068 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -8,7 +8,7 @@ from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC) -REQUIREMENTS = ['PyXiaomiGateway==0.4.2'] +REQUIREMENTS = ['PyXiaomiGateway==0.5.0'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' diff --git a/requirements_all.txt b/requirements_all.txt index 0cdcff9150d..d629dffc3c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,7 +33,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.4.2 +PyXiaomiGateway==0.5.0 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 From 3aa08f6c911ea7b1250a57cb0144a08f4cf01ea8 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Wed, 20 Sep 2017 12:17:30 +0200 Subject: [PATCH 234/277] Bumped pyhomematic, additional device support (#9506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- homeassistant/components/homematic.py | 5 +++-- homeassistant/components/sensor/homematic.py | 1 + requirements_all.txt | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index dc5e641cbba..621772e6e1a 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 async_track_time_interval from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyhomematic==0.1.30'] +REQUIREMENTS = ['pyhomematic==0.1.32'] DOMAIN = 'homematic' @@ -65,7 +65,8 @@ HM_DEVICE_TYPES = { 'WaterSensor', 'PowermeterGas', 'LuxSensor', 'WeatherSensor', 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor', 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', - 'FillingLevel', 'ValveDrive', 'EcoLogic'], + 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', + 'IPSmoke'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall'], diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 061fd27ca69..2edfe6648f3 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -16,6 +16,7 @@ HM_STATE_HA_CAST = { 'RotaryHandleSensor': {0: 'closed', 1: 'tilted', 2: 'open'}, 'WaterSensor': {0: 'dry', 1: 'wet', 2: 'water'}, 'CO2Sensor': {0: 'normal', 1: 'added', 2: 'strong'}, + 'IPSmoke': {0: 'off', 1: 'primary', 2: 'intrusion', 3: 'secondary'} } HM_UNIT_HA_CAST = { diff --git a/requirements_all.txt b/requirements_all.txt index d629dffc3c7..3158e3d225d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -615,7 +615,7 @@ pyharmony==1.0.16 pyhik==0.1.4 # homeassistant.components.homematic -pyhomematic==0.1.30 +pyhomematic==0.1.32 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.2.0 From 2531d545158d24b48ae868b1a79cc406e5e27511 Mon Sep 17 00:00:00 2001 From: Vignesh Venkat Date: Wed, 20 Sep 2017 03:18:05 -0700 Subject: [PATCH 235/277] abode: Bump abodepy dependency to 0.11.7 (#9504) * abode: Bump abodepy dependency to 0.11.7 Fixes cases where one's abode account has a nest thermostat linked (https://github.com/MisterWil/abodepy/pull/17). * abode: Bump abodepy dependency to 0.11.7 Fixes cases where one's abode account has a nest thermostat linked (https://github.com/MisterWil/abodepy/pull/17). * update requirements_all.txt --- homeassistant/components/abode.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 00f70c719c5..ca089a3a165 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -21,7 +21,7 @@ from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.11.6'] +REQUIREMENTS = ['abodepy==0.11.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3158e3d225d..503763ae525 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.11.6 +abodepy==0.11.7 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.3 From 2e66898bec4696c38efe6bebd8e4af57089fe624 Mon Sep 17 00:00:00 2001 From: Vignesh Venkat Date: Wed, 20 Sep 2017 11:51:09 -0700 Subject: [PATCH 236/277] abode: Set device_type in state attributes (#9515) This gets displayed when clicking on the binary sensors. It is useful to distinguish different devices with the same name (e.g. the room name) but different types. --- homeassistant/components/abode.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index ca089a3a165..73c4756477b 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -297,7 +297,8 @@ class AbodeDevice(Entity): ATTR_ATTRIBUTION: CONF_ATTRIBUTION, 'device_id': self._device.device_id, 'battery_low': self._device.battery_low, - 'no_response': self._device.no_response + 'no_response': self._device.no_response, + 'device_type': self._device.type } def _update_callback(self, device): From b8a03f1283c18aadac05f554227d49648a48ad5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 21 Sep 2017 08:53:40 +0200 Subject: [PATCH 237/277] update xiaomi aqara lib (#9520) --- homeassistant/components/xiaomi_aqara.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 3e22746a068..f786faf853a 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -8,7 +8,7 @@ from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC) -REQUIREMENTS = ['PyXiaomiGateway==0.5.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.5.1'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' diff --git a/requirements_all.txt b/requirements_all.txt index 503763ae525..d87abe0de66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,7 +33,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.5.0 +PyXiaomiGateway==0.5.1 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 From 58cc3a2d7a7fd1654aeeb87db456cd9c48eea61e Mon Sep 17 00:00:00 2001 From: Mahasri Kalavala Date: Thu, 21 Sep 2017 10:58:12 -0400 Subject: [PATCH 238/277] added services.yaml integration for input_boolean (#9519) * added services.yaml integration to input_boolean * added services integration for input_boolean * removed trailing spaces --- homeassistant/components/input_boolean.py | 19 ++++++++++++++--- homeassistant/components/services.yaml | 25 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index 3c4efdce175..e60f44e8ea0 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -6,6 +6,7 @@ at https://home-assistant.io/components/input_boolean/ """ import asyncio import logging +import os import voluptuous as vol @@ -14,6 +15,7 @@ from homeassistant.const import ( SERVICE_TOGGLE, STATE_ON) from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import async_get_last_state @@ -102,12 +104,23 @@ def async_setup(hass, config): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) + 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_handler_service, schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_TURN_OFF, async_handler_service, + descriptions[DOMAIN][SERVICE_TURN_OFF], + schema=SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, SERVICE_TURN_ON, async_handler_service, schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_TURN_ON, async_handler_service, + descriptions[DOMAIN][SERVICE_TURN_ON], + schema=SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, SERVICE_TOGGLE, async_handler_service, schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_TOGGLE, async_handler_service, + descriptions[DOMAIN][SERVICE_TOGGLE], + schema=SERVICE_SCHEMA) yield from component.async_add_entities(entities) return True diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 545a883be8f..865a6c7df58 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -600,3 +600,28 @@ abode: entity_id: description: Entity id of the quick action to trigger. example: 'binary_sensor.home_quick_action' + +input_boolean: + toggle: + description: Toggles an input boolean + + fields: + entity_id: + description: Entity id of the input boolean to toggle + example: 'input_boolean.notify_alerts' + + turn_off: + description: Turns OFF an input boolean + + fields: + entity_id: + description: Entity id of the input boolean to turn off + example: 'input_boolean.notify_alerts' + + turn_on: + description: Turns ON an input boolean + + fields: + entity_id: + description: Entity id of the input boolean to turn on + example: 'input_boolean.notify_alerts' From c26fb9906f0f5970a59f54c0b3344bc4aeadd608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20RAMAGE?= Date: Thu, 21 Sep 2017 17:00:45 +0200 Subject: [PATCH 239/277] Add reload service to python_script (#9512) * Add reload service * add reload test * Use global variable * remove white space .... * adjust as suggested * remove annoying white space.... * fix travis * fix travis, again * rename Load_scripts to Discover_scripts Travis complains that "Load_scripts" is an invalid name (I don't know why) * Update python_script.py --- homeassistant/components/python_script.py | 27 +++++++++++++++-- tests/components/test_python_script.py | 35 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index f80dea83944..b33766d84db 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -6,6 +6,7 @@ import datetime import voluptuous as vol +from homeassistant.const import SERVICE_RELOAD from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename @@ -36,6 +37,24 @@ def setup(hass, config): """Initialize the python_script component.""" path = hass.config.path(FOLDER) + if not os.path.isdir(path): + _LOGGER.warning('Folder %s not found in config folder', FOLDER) + return False + + discover_scripts(hass) + + def reload_scripts_handler(call): + """Handle reload service calls.""" + discover_scripts(hass) + hass.services.register(DOMAIN, SERVICE_RELOAD, reload_scripts_handler) + + return True + + +def discover_scripts(hass): + """Discover python scripts in folder.""" + path = hass.config.path(FOLDER) + if not os.path.isdir(path): _LOGGER.warning('Folder %s not found in config folder', FOLDER) return False @@ -44,12 +63,16 @@ def setup(hass, config): """Handle python script service calls.""" execute_script(hass, call.service, call.data) + existing = hass.services.services.get(DOMAIN, {}).keys() + for existing_service in existing: + if existing_service == SERVICE_RELOAD: + continue + hass.services.remove(DOMAIN, existing_service) + for fil in glob.iglob(os.path.join(path, '*.py')): name = os.path.splitext(os.path.basename(fil))[0] hass.services.register(DOMAIN, name, python_script_service_handler) - return True - @bind_hass def execute_script(hass, name, data=None): diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index 3ff32cc312a..660ed3c1b18 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -203,3 +203,38 @@ hass.states.set('hello.ab_list', '{}'.format(ab_list)) # No errors logged = good assert caplog.text == '' + + +@asyncio.coroutine +def test_reload(hass): + """Test we can re-discover scripts.""" + scripts = [ + '/some/config/dir/python_scripts/hello.py', + '/some/config/dir/python_scripts/world_beer.py' + ] + with patch('homeassistant.components.python_script.os.path.isdir', + return_value=True), \ + patch('homeassistant.components.python_script.glob.iglob', + return_value=scripts): + res = yield from async_setup_component(hass, 'python_script', {}) + + assert res + assert hass.services.has_service('python_script', 'hello') + assert hass.services.has_service('python_script', 'world_beer') + assert hass.services.has_service('python_script', 'reload') + + scripts = [ + '/some/config/dir/python_scripts/hello2.py', + '/some/config/dir/python_scripts/world_beer.py' + ] + with patch('homeassistant.components.python_script.os.path.isdir', + return_value=True), \ + patch('homeassistant.components.python_script.glob.iglob', + return_value=scripts): + yield from hass.services.async_call( + 'python_script', 'reload', {}, blocking=True) + + assert not hass.services.has_service('python_script', 'hello') + assert hass.services.has_service('python_script', 'hello2') + assert hass.services.has_service('python_script', 'world_beer') + assert hass.services.has_service('python_script', 'reload') From 7cd7b43d2579b723abf21787e80aefb2b31db365 Mon Sep 17 00:00:00 2001 From: marthoc <30442019+marthoc@users.noreply.github.com> Date: Thu, 21 Sep 2017 11:02:11 -0400 Subject: [PATCH 240/277] MQTT Binary Sensor - Add availability_topic for online/offline status (#9507) * MQTT Binary Sensor - Add availability_topic for online/offline status Added topic, configurable payloads, and tests. * Relocated state subscribe function Moved state subscribe function to follow the state listener function. --- .../components/binary_sensor/mqtt.py | 56 ++++++++++++--- tests/components/binary_sensor/test_mqtt.py | 70 ++++++++++++++++++- 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 7d40544d601..c5fba72bde0 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -16,14 +16,21 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS) -from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS) +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_QOS, valid_subscribe_topic) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' + DEFAULT_NAME = 'MQTT Binary sensor' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' + DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ @@ -31,6 +38,11 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, }) @@ -47,10 +59,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices([MqttBinarySensor( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_DEVICE_CLASS), config.get(CONF_QOS), config.get(CONF_PAYLOAD_ON), config.get(CONF_PAYLOAD_OFF), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), value_template )]) @@ -58,15 +73,20 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttBinarySensor(BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, name, state_topic, device_class, qos, payload_on, - payload_off, value_template): + def __init__(self, name, state_topic, availability_topic, device_class, + qos, payload_on, payload_off, payload_available, + payload_not_available, value_template): """Initialize the MQTT binary sensor.""" self._name = name - self._state = False + self._state = None self._state_topic = state_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._device_class = device_class self._payload_on = payload_on self._payload_off = payload_off + self._payload_available = payload_available + self._payload_not_available = payload_not_available self._qos = qos self._template = value_template @@ -76,8 +96,8 @@ class MqttBinarySensor(BinarySensorDevice): This method must be run in the event loop and returns a coroutine. """ @callback - def message_received(topic, payload, qos): - """Handle a new received MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle a new received MQTT state message.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -88,8 +108,23 @@ class MqttBinarySensor(BinarySensorDevice): self.async_schedule_update_ha_state() - return mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + yield from mqtt.async_subscribe( + self.hass, self._state_topic, state_message_received, self._qos) + + @callback + def availability_message_received(topic, payload, qos): + """Handle a new received MQTT availability message.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) @property def should_poll(self): @@ -101,6 +136,11 @@ class MqttBinarySensor(BinarySensorDevice): """Return the name of the binary sensor.""" return self._name + @property + def available(self) -> bool: + """Return if the binary sensor is available.""" + return self._available + @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 85e56fb44ea..396020561ac 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -3,7 +3,8 @@ import unittest from homeassistant.setup import setup_component import homeassistant.components.binary_sensor as binary_sensor -from homeassistant.const import (STATE_OFF, STATE_ON) +from homeassistant.const import (STATE_OFF, STATE_ON, + STATE_UNAVAILABLE) from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) @@ -73,3 +74,70 @@ class TestSensorMQTT(unittest.TestCase): state = self.hass.states.get('binary_sensor.test') self.assertIsNone(state) + + def test_availability_without_topic(self): + """Test availability without defined availability topic.""" + self.assertTrue(setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + } + })) + + state = self.hass.states.get('binary_sensor.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + def test_availability_by_defaults(self): + """Test availability by defaults with defined topic.""" + self.assertTrue(setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'availability_topic': 'availability-topic' + } + })) + + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'online') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'offline') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + def test_availability_by_custom_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) From 5fd92208122c82d05b63b99ab5c8e2007aa0ee14 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Thu, 21 Sep 2017 21:55:33 +0200 Subject: [PATCH 241/277] Fix typo within cover/knx https://github.com/XKNX/xknx/issues/64 (#9527) --- homeassistant/components/cover/knx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index ae7bcfee17e..b840c780645 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -181,7 +181,7 @@ class KNXCover(CoverDevice): """Return current tilt position of cover.""" if not self.device.supports_angle: return None - return self.device.get_angle() + return self.device.current_angle() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): From d978d584360ce3d2b89cc67f988cc00473adeeb3 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 21 Sep 2017 23:32:31 +0200 Subject: [PATCH 242/277] LIFX: improve performance of setting multi-zone lights to a single color (#9526) With this optimization we can send a single UDP packet to the light rather than one packet per zone (up to 80 packets for LIFX Z). This removes a potential multi-second latency on the frontend color picker. --- homeassistant/components/light/lifx.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 93412710987..ad2cf204463 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -642,6 +642,18 @@ class LIFXStrip(LIFXColor): bulb = self.device num_zones = len(bulb.color_zones) + zones = kwargs.get(ATTR_ZONES) + if zones is None: + # Fast track: setting all zones to the same brightness and color + # can be treated as a single-zone bulb. + if hsbk[2] is not None and hsbk[3] is not None: + yield from super().set_color(ack, hsbk, kwargs, duration) + return + + zones = list(range(0, num_zones)) + else: + zones = list(filter(lambda x: x < num_zones, set(zones))) + # Zone brightness is not reported when powered off if not self.is_on and hsbk[2] is None: yield from self.set_power(ack, True) @@ -650,12 +662,6 @@ class LIFXStrip(LIFXColor): yield from self.set_power(ack, False) yield from asyncio.sleep(0.3) - zones = kwargs.get(ATTR_ZONES, None) - if zones is None: - zones = list(range(0, num_zones)) - else: - zones = list(filter(lambda x: x < num_zones, set(zones))) - # Send new color to each zone for index, zone in enumerate(zones): zone_hsbk = merge_hsbk(bulb.color_zones[zone], hsbk) From 675fb2010dfe3b9d009dba08649563f0e242777d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 21 Sep 2017 21:18:17 -0700 Subject: [PATCH 243/277] Update frontend --- homeassistant/components/frontend/version.py | 4 +- .../frontend/www_static/frontend.html | 2 +- .../frontend/www_static/frontend.html.gz | Bin 168127 -> 168665 bytes .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-config.html | 2 +- .../www_static/panels/ha-panel-config.html.gz | Bin 34595 -> 34594 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5139 -> 5138 bytes .../frontend/www_static/webcomponents-lite.js | 320 +++++++++--------- .../www_static/webcomponents-lite.js.gz | Bin 25865 -> 26084 bytes 10 files changed, 167 insertions(+), 165 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 87ccbf55075..b5edb751d50 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "6b0a95408d9ee869d0fe20c374077ed4", + "frontend.html": "7e13ce36d3141182a62a5b061e87e77a", "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "0b985cbf668b16bca9f34727036c7139", + "panels/ha-panel-config.html": "61f65e75e39368e07441d7d6a4e36ae3", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 2dc0bb5f156..60713690c44 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -70,7 +70,7 @@ /* Silent scrolling should not run CSS transitions */ :host([silent-scroll]) ::slotted(app-toolbar:first-of-type), :host([silent-scroll]) ::slotted([sticky]){transition:none !important;}