From 3b2bf1d567d8debc80284a0c417c476c3acba6af Mon Sep 17 00:00:00 2001 From: Sergey Isachenko Date: Thu, 7 Sep 2017 19:20:27 +0300 Subject: [PATCH 001/101] 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 002/101] 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 003/101] 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 004/101] 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 005/101] 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 ba310d3bd1ba9401bf49ac55717c70626074c151 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Sep 2017 00:52:47 -0700 Subject: [PATCH 006/101] 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 007/101] 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 008/101] 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 009/101] 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 010/101] 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 011/101] 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 012/101] 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 013/101] 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 014/101] 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 015/101] 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 016/101] 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 017/101] 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 zcmV)3K+C@$iwFP!000021MI!&dfP~nDEj|C1%#Mk9PK8Q?-D3Jgg0JW6J?>kR(AM8HKjfmU1~>ap2Fay2Oa=pimY|{DmF?2d_Cux~y&Ak-UQyfItRpZ?KPixqJ+%=c8_^_A z`N$ql>+v6HJoLfqbc#_{mV6NWuh1?NQz&Q0S}LleXBfREDavsA@IC39&_v64!(pxP<~M-%Uh?Z zx{cGT!BkO4a~F|MbU_O%t5=FT{~8k8F$4+K`gt-b_umS-4Q`X7(?FqYl4r19Wx|Wj z?GQTe+?F>J-g$HS8X&%ah-sv3LZn zUgG@w@klir&gWql04@Nyn9HT{WpW-97(ML2oW$iY@&|D-Jk8>Ku(KBb^Y;bC^r6OW zg;K?r1RA;RPxB(nJCh7hi02mio7$!~F99UNap!!(FYDIh64v@=qizxbwM%vcxN|vKDdy6d9)NgLpzKC7UJe z2c*nWV1PGn(PvyNg(Per z-KfpNlvM;r3nbq?&GYz1lFiXAbQgoX10u+|&-wy>9sft3@*MD(4Gfq%{?G9!8Q|&y z(&wHS#x$oPcxMS9Vw?`}HL#uE5vRc<|Cv7Dj*`cioCJ-Fjf*_eAzivnO8gOoeKxh><Hsj=^fk#~aZg96_Y`FgYG9VXBVse%a;u+r{EX)u1|T~_f&p`>NdHiJr;%Gu1%NYwaS5egZc+xE7vhil&Uc&D_+o}{2fC1s(9 z+EizI8!3M)ippki9pVr@em%)KKmwW_gQW0yLK@mB@Jy)fWw`}E<{^CjN2-Ug%iADA z%M4#cLTa}A#Kr^YzBC`%ZQp>ob1TFsXTT~zLdUQQpo+39n38zjWm-y#-@t(wv>3=f zfVaF=O}{zoHNGX-ba?yZghp|40&tjvT?bf3BP2aF()=^L)!^jAWO610 zeg~yE9dhdWIx3RG$dka zXgNf-s;;3>2D;TMT_WxSgxC@qq9QO74gJ|n&h6Y?B_QTD*{h-o7@7dTv@F60G=s1-oCrVQB1aj0v>zWGTYh`MafZcIx3yF)$ zR0}lh5$O;IwyLj0t}}uiGvp2Y8a3AeCUH>30--L?7cG6=8U#(q-tD{BJXz6_D~7oVB6dPk)`L0+d9j$F_}UiUU)aW zWF7$7yW)RpEvu?UEl{Fd302dinjFUs zeq*H-HL>X>ZLappaOU4lXETbVkyb5TwVseit<5~p#=R^s%_wUjt1M_S~#wDmZA zvAGI&DP5b`d0jQ%kw@!*1na_37o4ylIv})SuS{T3P`Lxj=!0Zyd%tk+A_a={-lNP`1HDQ>yp{P4 zs(0h~LTZVli4q(ek#Jdg~Mh zyv)w4yOnjaTE0o7+5$K&Y)TN3Py9eW?;jnh&+q7sb~xII>*X=3TX^ca<)Iy-UK1s^mvx2^G7} zlO-#-`!KN6o6)#xq6`e#uPrVx5qmXhT1|V>)<&J}Azwyl?>KIRz=vgYc1Wxs&cW;h zm9^qKN2ZZeE5HuQA)4?x|Hm}Rk;%mH48?LV4u1l6fUk5}prdI@&!8-+dL;F!2lA*Zo_x}~1k&0VoIkJPW z%2PqV3jirAsd9H90-|69A3bcmlJ^||D9saKLh$cKOV;RLxz7aZ%YVzmqt#DBB#}Q^kDU zy?uq?IH}VlP9MN_l&2M~eH??|=3#li2&|*{c_=gXah9 zc-f07n6QVmK+Q+mYRBKC!{-ny{)OKZfz?DvPIo9)$gjS8uw$VRZ8}k^yLyisGl6On z`tTg(UZ9s%{?H7_9vt(mCKPB$ytp;(vvP~8uh3H+YeLx&)W9pCx)8oE-?k0q;d0!zXzAm znuXm(PJ=Wu;92a^W77$;T3M_rVqD4a<8w zWO-@8$5|n^6_Thne=aA_()zbTe(M6@lI`s@?DA2;aZe}-MZc{*{FcgJ-k$QS^Cv~G zwb$YMMQ^XOJFiTl@z`IQQ!JFUM9C72Df>nw2N= zEifE>>9j6+StCp8q%pK z>-DK_fx5~h>0hW$Jz#ns?GFz)8hQ=B<7Ik+C^Y~H0JcLacKt{3DIZ1tdu-nK)V@Uv z^yYKm2|^{+RY#y>oo3f^y1F6NgQun#`M0;~nOdID*HVwf`YAn#emO^0_hk{(Ar>8+ zcSfpzJCpuIhOxxPyNO&;**Svg4GFY`@Z^B)>p)!Ad_5hX@*IRSh_%5@Gub-z zq~H_Xw?Q`XYurn1E*$Rf_JnA*-$t9X&{^oZFUn zMcgj@W99&?dAs(=uM#S7FljUS$7qH2<1(-%Emw0Cf27D z;)x9@p{n$_J7qj&VG4mzmJ1}#tpl4ARKnasVP`iILIdRlZ(QPP3;z0t-XCTob?I5S z_Gy+6^vyqdNp~$gP|@g%HkaW;k>So`etsH*IQHcK+Ix?~`mIfK6Hs?hxqbL4PT@)B z1rSh{g*k6d!)uNBS!p&xj5 z@&hz%0f_H@YKA7!)^>7eb@40est?n?xXWcXZX z{nfoG{2FX_*=dvkLw4CezTcStFTV0L+*@^@Tiv~1a0hC!3OwA~KLpLzdx2YTdzula zuoy}e{s8I)-bC^}@@}9mJC(0MjrGWh3~FRi#OLtwhd2R?3RQXu7r8Z#G)YB~NMZ7@ zN~nJVD~G3N@MQ6E?7eX)Z(#~o8J<4ZLz&1|I21Y@=}&Z_wYs^J^X&wbn866$=rBbA zLA+4av~&MJx1iFd^SS9qKcAa^lJmJ~!};8_@qBLD<578KEP^8wv-#R1)PjRU&r>I1sz1_yN0hHFsIFR}Dmm!ML835vL&w8<7- zgd)BjQJ@G}OEJ@iv$=cUMrdI{iGyqawv{kjZH5VuM{40X+-Jg|1S`=rZu8dqh=h-O z(H1K#L+Q&yzdJ$#F)glyZLNMWB`CyeEl8^Fb|3&QjYAOD@*po5J7>Txh6uD2!8I+S#5vyV2xSMEA;B zM;w2KR*DRd3DfR2_SgZII7<56Y6u}bgeaVny6P&;PXS?7M34<`rZ)r$QN=hI-=igg zK^(QJdiF=DS(+1o9VXQ*cv?fxvZjpy_4m=6UpX_FO~e5KFg}to#N(v5m(R$@s>|^RnQfpGDC%; zmC)fng}sHi5=6HcPIG;qs?runG~g8|s_O>>+fOF@Pm=1mslk-?%8Ox)yACNZe{out z?pJtpWr@b9u3J#glRUvKbe@kB5}|GH$xednxWC5nKKs`D0)oEtrFr@BPGOE3ZK0(b z0am%y2hF}-w*RUne$^8HxwOQu7wwl{w7120U74(IhHb9kJ($m^?XG>d`oXzr-^InnTRZ7M-iOe`Yne~X6W;*BP)lBs2-S&` z99CyqbiUjDPk3{U*D=u5rH`XrckSyI0T^dc$iA8IdTplj^$_ZFSt zf>EjJpcl7>Fz^u$Ud*f`kKWM#hf7PAd7Q$!ped~i(3&a~5ueX< zenWu~!et1i8>nNN)y%ilOv7kZ_3m7Up1f@+rayM)sN3FJQQ+-h3u#877VvV-8L0Ce z0dV1A)h{_%T9X2;>zd=Wp=a8zR`5g_N5MH-XBn`c@OBAc+@cG?GfC2m)}IJGYS92& zFG>H8{5>C5t}-gkJ9N3$UQ^x2>(zbSRQKEU>VDf)_mAt<{bN(z@7AmPon=v5aZQ1X zHXVV!k+nD-zu_P3JoWYj0j?CJXfmd{nlveQ;zkW}q`S}d(rPGPPTV49}owx{-!Kp~;8Y9A8gGutm}JCY!3b>o2_!%7R9 zljlC^*^0xgVQrr}EiI3iP=fb&XR}g2yN7@r3$N}NGPhM*ct9OJE|x-IzAkkU8OM;2 z?CVE9)<|%xB_cU|y6R9UiY}45Vb7LgdsfvW=|wrdRcRmos4I!w7O^W2Tv`4vO9M>lSqr4P;s?R#RJt}5{%t@3shQg4-dY3PhxKfWD27x z%SqAM*}+@&Eq$%Non(OT{bI*LT+!i<@0DQC@DgD=iqi{!A01YSG?oO=;{ArWI5@;1 z#$neH9tX7`Az7XS6h}90Cj^ZfPvI@y|1f~S>#s1h6~S?ANC*P2#eAvU4cY$%hdLb% z(9r=_0^Xawp{`K(a6l{kXDdU`)UJ0}ZKyjQ)~$EvqQ|m5TaE3evPOC5M(XQ$%mY)% zy?R>N$8E^<&{XpyW_(z68AUTLMRMP`CxK1(lhL#QycGnFH=wO-1_pS8!BZRf#$0=l zQ^9os@sU!!SV(2xmRWGG#x7qy6cSy*PxMuqHkxmCYM+V!5T}s^mZBw|Ntw33 zE5fd|rca`xnItLlpq5Zj=usM-yGPnPu(V51=buW>J z2e;>huLlPjv;YQxO7L&kUG<${dL+koe7j;6E9anwKmU*H?Cc#@W!`^eDLpl;T(e^j z*K=2_=B}MxPet$CC6{2;Nocyf6(2i~^<}j+mSy#l7=KA??ZvLvVV>oZldB_OA}2(p zyxsQW|1NMw5_0Qzy>vQiCUxW~0L^)@YvYUiN({1A(Ic&24A&~x_#O9^r7ScBw97@I zVCYrX@j<5C)Qtr0LTI$VYk&v7lsxz)Is$HO!Ia`Y`KW=rHj1=njwlaf68bNkGx;D$ z{-LMC+uKF4;R;0>$qLYkW>%yAGA`}7BW?IvFj`q(Euw5km`c~=F3uxAFGqf*MH@=H zZu-d0fM%~{qdUC8bG*y=K`JjO4 zSL2Y%nfY$+OQ${Rc|pF+bzGW`PP@>zc_6?gcl3;p6TzWfgkTBfVDEIqz~ilk(L5oGuFd6*-9>^NcS*lZ=8WG58(8_@L!w6ysEr&{N$i2qMz!N39y;E{pNnQ1`{ zzU-*r)e4;Tb_%`P-C5%GWaDPoS*fiQeG>!M=mNbq`{B8}u1O=T!4IRn@ygw+`2qf9c?xIJXy>!-A1IEO>Y`KFvnk+kQb0)eo5d zG>^+H?=_sGk|p`P@sO2$p0U;)Z(GL#LGtSNTk6>yW$UT2b##Z_EepSWjEsf;xPIw( zt+RaL?c6%oR^l~XI=#no7PEwnqE{3VFo`sDb?m=Z%iDk44Sy!TX`sI!CC4Gv(f6fO zAoOH307Dq4{%2W#Hj@?2*N_c>%1iE9uez#y7Xu9jc?7kx2?+P?ZDcUsF2EwkSD_eY zZVgY5EvGSIm_7egFk1uc9LAJKIM9#AHdRHi z^QP@I8<3MYD%n(nDjLBL;}MU$T1Fb&&OwA2WCF$6;2GDCU2)LY>P-gr z!n3&rsty`>S*VaEN2JL@@r5EL=Er!w8~#KY{G&rbT1UF4V&3AsaoHOHLq}dCPg6^y z?e$?m`_P@8ovW*>_SLuTEI;3Qyt})*Q(T_=3;^C-5p^haLbwT}oLQkFCI(D+_Zq!> zSg=bcx^q6xF8Kk1$HBZpU>*auDvT-GEXKykqQdD%;+-EdA1IPu3zp?!`JA7LSIATY z!L2f2B{+0lNHnlT7?4w3R&fl3Dq^=8im(cAYI+BF33N)8+x?)_LTuE8`wtq>H%VgyQS`2v%_{Ph0SO97^gSX823Mp^shWLScnlEk5nBKw zjPCcnOp21HXp^%(OYq3PK(eNf47uw_zAFf%=4W&}55uDph|GWjKN7;gJP?-hc?)S9k~>tD9fY2IFllsGcPQS*YQqEmO`6-(zuj`H640IjOW<&HCC1&jBHqRzT1cG1`y`$L7G&H0drB7~IN(3@< zbi%YxT;gu*(pl$Wk^|4Pq$qUL?Az)Y@&cae{>8W_L?fCX5} zlk@bAx&;i-{<89KzR0J$NpMi|{O3VXrjqetxQ=;}DS z006!LZlYJ^!mLVfE1%6S48Wz!yOR?Y43EZ-9$^D>Is5}__y=P61LChlu2)fjkR89l@%fP}X z>g}F9iN;-12!IAl(rO)tSag8p4DO!VwvEl)yJ_!jkOC4&LFl3wawz8oFsZY~m(pAy zWldjut(2S1(5_)8@Q-~9n65p-gun)(fIWhX@~&k8NoS@6V^{J;29n3pTW*woLk1(k zC==Sh9#DCd9Sdd(8GY74#aPoqV?O9 z6uP*0nJZsEwXEyVFCtI)D~jNYlG#w(W5}G)DZ3~BlI8V%8Si2u+dz2=GKqYR0!nj;Efd%U<3VQQFpw)+J>~V-GM!6}nk68mCu*DwM=bKD49sjvf?G43uygr> z4$A_mf+(**ZUB;9j2FZ^Hz+!efYaTu3y06LYXAk(+=*NQgjAo&p+x5vv@h9X!9?NX z5D)cNz$8Jq;45gRsJ!%`zuhYGsQQhsWktP=6Fs=X8r;A^1sg_D6_GRr(-~*ulHFrl z*WoP~I+8`0j&VaCIqiW)R!|@Uauhaw2U|+mXNT-J_CAV1+OM(*-V2;~9gPAo9Dl!=+Qc|?4GwuG{; z7Yx{>Xn0pN%&y?q9$+$BKI}KBkH{tL`e`&~&@T=*m^zrHwiU*+*&cb9umt{BFY{Y; z7EDgpEP(u`Cek7Xb(Fe*+YRKM87t0%p%5yKMKFCz^bw(!xbc(qB7N{5AClar7AU|uA6yz4!WUdJi2WkkDGgVV`wC8rj60!X$&JWaa zI1xC^tUZ&rsHlD4J|&g@LpoO8E$0O;mQv>E(txX7RJpQhrTKlJ2}he)&6+(q{;O(z z*`(H&3)OmynU`#W9-Xn!m0c#V-q9H&)TYhoUn}!SV2+(P;4LgcLu4FaYj61xZj~O($TxrCH^R}b zleC#_R0=vG`1@Li666VnpmHi9Y&w>pH*UDIaif|5L*-MQS6Dp~_B3+uTR&{jE0ARW zhH;KwIxw{jGQ!8+qA`?A#-~c1_*Au+RCc_oIoL|HsR4YMeNcpg*M!g0+02Qtq^W7I zTRy3sInklwQ*#~#L&mFW(y1UjFh=t*F5X5zC z+2|5Cc`;0?$VyuK7Ipx7?~^glrX_n3?cgE%8NUvConm%AOJ|eWH{mRw3}@L@F&n4- zSz2DrVCYBv;qmPEESvpLmJfCkh6iETpYfl+M7!)A+HrhLunj zLLD`ty(>kawzdQJg*YpcqZSSNS_43MkHZHeNC7JZHp!FhuKpY~zMajEj->|;(=Uj$ zu5$)d-{(S)cZ3zcbL$)pJvvPIIK;#ESk6h=ITvc;H;WQHz^|t&nS)d)f7~o{1Mw!R zfm@s=5i4a)O$w2(9hEfsN@TG4^WmG7lnfC z>UUs_VEH{axgAv5reJYOXSOvgP<*7THj@us+}2LTAnBw(EhA@dMwru?LrQ6tl1+=9Gs2Y|?wjg^KI8*ORJ9^2Je#%E+ z8Kn|ladbOQ(qHKMN&J=_pgn(t2-hj z{M(nrESI3?Mog&8CP|<8&m_LVc&ZyQpZ8c>@tsoqScBy>H}+Nxzzg0`NR)UKs+Q%@ z7I7uhwzZ;vtF9;w4};Uu8vXrWaWSdXiMqfiq)J*Q<;_Vl0Br=!6h6wX&=cE^PqRzD znV_MTycYv4XH?Ux*JWX&PjMKk!C3qdjU}5m_C3x{HFhd~h{obI8mnnmKAphvbkv(5 ztKy{#Z-e?PH82ZL|6b!f3alDeIsaEgR)d*2@gYLcGcchEKfwlkVC=jDNBRLqquM*8 z=k?L4#wiV(T!UT`Fo&QeOc5-?RsE3V9nWVKqa{&Bi;Yp7uSTizi$bY?PtA;|AUit) z!_*i6A5l@uKZl|=P4lGSqdN#7!yk}uK$)f~i* z-#3b;8K~uP%|!i%=w%p+&SYA^RQiHIUaWzePWh?-;MLv`n3+FTgBJd z(okrH(Id>g&@SF*tP^<~frl~Vns+Ihc!jEg8;mjTlDn(F5pxiYW)_>sBf~#|IY0-Q z9^)|>P>HMrz&0N=pyOUM`xw9!&^Oi^^K{yxG$fViFcF%GV69#{A?YW2h4n+S_=0pUP(H&))MivJ&q?qv*0x0q2+9NI1fo>jDg!w z0HKsgu-WV`hf*B}4E~3-m`*Tn3LkiS`M~Hh5M+)~Rx!r124jp(Ke&p6Jj(KE_iIIj1n~R~U@F%sg@8s^^DmQiV9Awi@lLi;FS74XVy8N7$=5a~)HeKg{yvcZ{bdBR7H|5wXXePtu6!_^t2YgO2rRzqWd{(gvWM!{itg zU3`$0g-*HznD0`A(s*ovyOMnDUJ3DURY)kJL;u+nFKnP*9@a`y9B(C|#iC;kvHGE) zoCX-%U5_~KVOvN(`NT*b4+fGOMfMMa=!#ETvc!3@4oR4Hm*&&02nv7`06wqv%jSkkssS zSpl?h;^2Pq`K=~Q%#esfkN0!W>W04VCj67`CKPMKD0rEDh9;Um?c z5DBX>Jzj4gAa2?R7%Vq9g@ve)qW^^$rSuy!g!96#^(;wrJPF0s1P@#k9_^Pd~?XUmLBl zUX`c#>z<2}H7$zO%{psh5%*_a5`xI$6|qTV6}xU&kjxRz{Tf3Sym$C7Ado-mrjj1r zeYTZ`>uxKZ{mbe{hV=_hvyoCnl;Y6EJ(MBA30=Oo(ebXc1+;YjZ=&3y^Zv`&3-Uxm zUNYEhSIAn-^R>1{!~$RP=vJt%176wfC^&6*^sViVq1^5`liMB8F*|j)J1)(3$H~9@ zc86iK`f2?g4_yVfJT4z>%fnDXWz*vlH$C)M-1RuMcRegU{65zv?~M<$ zChKo~m^!rgVc@^<=Eu#~&5y5}AOB*TA1Bq#kMXD9{Lnlpa(`MIAew58e5t-#a}z|j zzRos?BS3KX!IBDUA7tgOM|GfU?t9ew{!(^6%)plHecV`k9|mU5?nf0aAASErFTLCW zu{2wU1P(sG(jE7;=dj@UB`mdjk+A-99rUl)da&bTV!*(>?r@7x)+Li}l*EN_cZ;!- zBX8qdgio;0yjhFiJIA+BGH^EpUGmL}!AxNAs z`NTyTd~_}tA(M>Ax+>hg zaDZtz0lTPz^FtHUXVu*Z!4KYiNu1+jpWp>OBfe2oF%UnLi%X26O>fsRncq0eAsc&B z!)*GXZRe}6eEM_oX*$1o?|a_P74HpicQ-NAd^3i6>x6Xr~F@Gr%_gHbwm>ZiTyenQ_#YyR&@hC;Sn(l@k{a>J=nnT}deT@?h zE*O_xrxSy0xGOA&zmfm(+QI9}ps#EA?G!*fmwjqJ`<6$yKE|Q(lq%FY;EdRR=M85F zs?IfMTD|UE09yW#@&MTf=_X_A*DcX2;H?SBN{<7f@vKjUvzKjZ69?`IUuC$gR`52K(~BNv?CYx)+S_SbbRiiED(t7r`M z)$T-RU~Bpk-R?f4BT)>5+=y~aOMHm$Egz!fzK#RYfy77m9*V`4u0y4CJ1G6^a~T7? z5-4x8I|G|J*Y8g#%L4T^pA? z1zq@^jL5c_#Hkj1Lmm(4j%yy)z9ngyXM?F|2PpYN!96qh0`-$q)XY$0$vwQ1>cI>y zdE4KBwKNG_*V?0ms!h)6CDxsgv3G}ty6;%{tHGj*dSDWJSHrmUC~g?QXUIK3nm&dJ z1}J4`UYuG0^z!M5x4jpomlWP4gF>KQAW(}5?}N7Mjlo_?CiqVEaF*rNY5~BI)!(Fj z0J|z8yJp@`xEfE@4Poyp0dw>c15M)X18hQL6%AZ?BK$;^kw9BlI;w#ez9XFx+4d-18otr>G&t7qd2{gcclT3$9OU}K1HKm zO}6ceRm8BiigQ%A0ID~F?z`&RCSPaWO<`JjSX*^~)SJK}G_SXWaf*SWF?~I*H-_EC z*o#YM*ekA*vOh#pIOW(ivNr$^U?9{pJyK2Vwcwoa)~eW_<~Tk0h(n`u zy>~}np>GvChECQ)UsGNo@YvaDNiZ;R1&!-lyZ1%pr^?H@l6h%LxA<2R^B80j4TrW6 zK(HIcyf?7dg0LK}0`F@KdrkE_qY7Lr8n5Hb2E_|-@~NK5b69~aw?moRvG;!q%HSDA z+((^z&SdqeNDcCqa(Gn2p#K(F7LA?zf#GM2*57?1C%*q=kd0eoSQntLWDw!X8yL4Z z4bAreZoHhtvw+WRkTL>Ki;bCp`w7rOyjUl}N# zZJSgEgx+i6lj!s*&<0LTIXP}Y5DP+$Pm63c#aQXk{BD={82tUcC2Xuc2UwFlfx+C6 zXS_upLc3MZ_y=x*OBGOWk|m;VB+nxx&d8?CV=umK>Oi$E8a#_n&4AC6Yd+}8MIm^I zG1H?Nwgj3i*ZOZ{n za{mbknU-K!Em#{Q>|tEAV3D&C=9~n?J4g3}@7qN^0+eEO9!*&nDYI;J8t3~@YThCb zpCj@W#XtvFtr0&feGn6^GM+>}28@4?LT>;q?bK=mc_x?MI7U=U$`Qv74dd{Rjwsd4 z@sk}W2KAkx?3l5OW=Qv?Ep$?Q4egfj1xUh+&g~FQi(3jiE&Ky>FdcBs z<;(9yYVSEKKwWtSwCTQ=rp4ACuxWy>)0X@y*f;@cVQnF~7uPn^aLY)wDDPN2UYV@W zDrN!!>Uhm7^Ren#WlTjfpF|CjXPaR}{VDDT`0@$utiW^{LM?$C3wsiubtiIo(4-~# zl!61mP)?BgE19qW9X8C)=D~}n#{FSK%evt9c9TpF+(MyR7um0A4g|K!`ci2& zu!gI=eF8N^dNO+9X4yKKQu+#=o()rw*ehP|oh2|I)fb|0FTpg4COvjL8Bj_$=gn-U z%ahCN=_CpKbG)nOi}Bhx*gSNgw#OPkT!Udax3I*{ts;O{gRUvf5WH*`*Pu=t z4hrZcXdIb3DF&52i_hs`6-V``AIH{dPEh-$!|2HNU?0yHO*m8pSR1N~tYUa_ho}K= z%*eMI{m?{Vx%3v)-?J$QVzA4mDe`kLR;lv>%rLn(Ra7^rDDGL$4`(xI5g77K8#}~~ ztAklgzR?1OTr9^oZ4_ke)?%90%V~*=!%yC0BJTqLg4ZxYP6fN6#=% z3)rwg6_MJQS0zzKG_ViY@cR>oA#I&MSKosk3+(9W(gjL43%?J^K^vX zhcX$pWeu7R$t$#^mK##igfWS?m_{nWl^1V`%d|l z)3Ak1wHB8stXrs53Bo0nTXLU|ph<;tIc{2sN zP7rkkS}LPbb5@$4R+-fc!U$DZa$5DxAgwAO-n_!(P|%SsXX~{3WgWBiHwy=(W=c9n zU7wN4Io$)LGi3pODs*ww+=I%LMU z$i7$3W?NZk`J8kr#W>pS#wOooEV7rDNAYn@*R(gPOdiwN&0|_0P~mWLksRwtSF+Ev z$y{eeSXU=iCizH}0j;rX@oFE&w?H&J%g|5{V$AuM3@{aEY)x%@I}EsTMrbFQx zU+r9~?G`MMj@X4WJK2RNB#Z3GlyY_q_HnP(*57{23Qzk-1fC?Nr9H|AY>$ zBE8st$sxbQ*Wz#?)Y65V7F;dKEUwV6T*6uHDK*_sM$crZ9rfOh<$ z_xFUl-2EeYK_$}InpU%4aIVT$Jq@4+C~Fi&??O{liX>%*(v1|+`BB*hQ1FpB-n&28jpN3UI9LVdZ0KOBt^fyWf>WlK&-VlKZ($gz(r*hU6B6 zX%V`Pd+{<{#^lQnlnv<0kUbdL5rcR|Fe&-7FdE`H*VkdG9Zdfxo(VR$_*uXe{dWmB zCmV~w4mRdKHz%!D|q zekNz{>!l|QvK%ji5SB_@Q#Y^0JUbDs^PZNjXPvzGzwL~KyHx=}ajkB^g&x{hiIU>b znEDm4z)S&yzc_=$f8>n!f6E!HU(&_-Pzy}(9_hwaV#6_Z7GB=LC%LXO8F36)X~gN0 z^+w#;D{AalmTL~&`#OCmtET_U%_OrBV!>>0%Q*mb8LRjV=ih(5_(x`}SS@DM z)zz=6?Kd?2`W`&o-QCMOcwexAdOAsDHpvL=fvCI8pA;yb16L$t#;O_&Fg>)_XWuxx z4k}9-iUpaoX=h1SXW zEJlj&VY(@;@^WEE9wN;g1+zXPo4Thf@@I}W8U~^$r42w0IhE@MP;LnO&_tL&X5m7( zf>NqDHkeDwz-7D4f*s;Ez>ln5kFO{#mqy_~h!V2$7p{2W z;+2S4Kw&;5pgDiCUq!y4Pzj)@eq|cqxtEaF((%S$WdmNoc(tHa=AzezLu)z#B!s4HOaah9u*LZl zCzTCLi5&k^&2TcosYFv)pbV~F!@-o+6Sd!`q=YNh-Nw8P@zH1G!vbA_aucI*;G z`{!o-{<#@kk^goxU;lA4L#;IHywNs4ytrcKlWo{Vwo3xsJU9ePx}^sZ1QTq;_0qOq z9ld-+U)(t3R$}uPs#pn7sB1bJP#SAcRASmK%CD{&IYrhT>T8^oTF^D_k1$M#VodYT zd6y7rXLtsjM2qi=z1QuS5l0D%SpK*uUkSaS)|BimUV5df4LW~QgcR8o{)^4b4yXYs z1o?RrTs0}b-iU!l#O|E{pQcy(Ec_i=`5myC0B}OTa;wkVbg3ucL)p?95UG#*>*}({ zV5}}fuzdcuSbR$v=wYM>(Wie7#t6@bCJor@n)C(DziizqA0)bEqL0w+ zeSUj+@Dw_u3xD|&CNG@?j@5+;9y;tq%d+=(s|A*pLT6BA4%lmYV5t1^2A?lVYqF-CSRj1Zg!{p)V86*{|V2~t1jzNb7U9l4$18iT3eL6e4tT;a0m;z zC+eiQ)5B7oq(dXs{(txxumAWNpv}m<1&c{6{v`nz#_iBDKb-fd`l4yUTAIcuPY>ev zK)nWN4H#q@-PCJDuA@ex*ks{E1UGa5&)E5VYvy7HN}`tL^>9M>AY#EV@YO!yE~jBd z4qjOxC?o}4ydMyHTnCpD5l0c%}nP;#6EO`imE<6%vz}pRcsO@K`L= zQj#r@KhPi6kDYZ_jAD;K=EIJ+t1cqLS?t7_mx5a{6~Ql~&MpuF)&fwWGAnU_%*P+s zKOW~y>E|JJ5gGfdNzKXM5^?xZY(zG=Z=mRipPoTH<`#xIz`2#n#IMReZ`d&{H8&a^3j0PK9}hp#SY> zQq*hDOydL^(EcZ&N%;?;=_b*TY4|TdQ>D9E;_J(F2b!x~0!^IYb?S85Gid$YK(fXX zsLMoP!j1syNy-2#SwEI`ren2C2TaYUcwoY5l$cv#ZzhehnY9<9JBisM3seGA)C1DV zFX6_lhjg=9aLUP@J7c#NI%NJAJpjb=T7?1lx7EO*LXkA=`pIRZLD8*a#6}`IlOz(u zD@+zO4I|&FeR~vdmEVE2rXu5k2x46W#Hzc(V3?wcD4FaaiA1d1#C2nB^(}iGS~fF& zzVqNG;;*LXViqEC!zK5I(h%S0v|h7*L#P6eRMFe)fykEyC}YsTYX{^a^ppGioqyIZ z8J9;2O!TELIHVWSgsgHMdRq*-8Iwb*D0r8H3bN3|NLzj#JnRcjage(Ts^sVwJ!RL>sl9VtbcKZW^ z=JLX(F_^&V!O!v*QESjamoT69;QiXz{A#j@2kM|&;}PV+0uW+Y|K8B@v~~Ss#~FDK zqpAt$ax5@_)eea1T;a)}Topp#-P11g2!;{7h>?RhMRWW>`X7J>+=_Ob!dW(eQ$aW* z#WdO~D(-nRPJ^UPU{5~b*RBJJLx!d|MR-L;Z&Ic%e*`U41o$kh4qfTAp@6P>cZHMb ziqyl0AZi?YV-}mY)2I5%dy8bPJZ9AsJF7YB<7rGNMLxTvIWoBP&~a@8UkL3&Z)I_b zlvU{KKrRxukKpeS-dVG@L~6FXky9)u;?Tq3%EdZyGZ z1!JAdY4Wj(oaCi@l3Ja}L|>NvXLoP_49aIJ3HVsW zo>-XnbxLfo*kG^*fiMDEFD8$3PiNr&^4lF`A%#57+y(el)kTC~52>i_O({lk9+-gfw)shN*Z98Ca<-Hw53)XeIpIXs${7#=u%|1Tw z&MIlHe9%Rp3KPOmRMnc**R%n^9t8z;5B|Z6P+)(fJwgx1va8@o>N># zW{M=Q6cew^6fIJZ&}qs*=}578Qn=k-o^;tWA|Rk@W_SAciw9+Hv-&CfH#YwG&w#(5 z{r)6SFf&{|Ae-Tl5Oo8v_F^Gsj!gq{eGqX4z}C-iXo1ABJOHa1oLc*u;vF4p-n`Z${{xwM~f)GRhQU7n0t0s4gxrJ*0Vn?k4guQJ_ z0Ko+NWrsA$vLflaDw2?`E#x(wCcvWaE#|q)J~^sTo{3Fj4#|(w9~WVZ(iOAr1vr|e!{B6cEZCMtx|<= z641xg>VKbG0RQHn#jF607&oQQn8YfDfC_by8AWoO8lPCN%qUn`beD|&7L;Q~(L$nb zFX$L#y8iN8^H?VD|CbwL7$F)Yptd+*4%W<&wR>&lNN)sy8$l!~mdecHJTMesS*t8x zHh98r+D(nh>vzR|sS9RX1ymUp`e4u!MjwrcZvspjHH)JmSO~wT0!^O1+`Os zI(rjy9eYRR?>+ws<+|)Q@Ch|XNG0&wa;%Wil^}a@(pZ_>(?*GB-taxa(`GSz);O78 zg8+AU^143O(#CI#GkQi}fX9uaOsux}!fLuvxj0SEO2tz@VIC6fL~PBu91P~W={R4zkd!k-kIF@R08p7bOn2h~f!o0T z7hO8xDaS1RGw_aV2Twk4pXt{Hia?rqfd1mz3nx#qUZ&r#hrF^;jH3$x=Ja{wVz#oo z`Gwgj#qie#%P{)u`wJti{*}mcM`p<1Q~zO~m;Kbl^Av{c&COP!uf*37UTW7-t3WdPT2^kCb?t$(MAt0!r)>F#3jgJ3ZDuW3&CeT-HZvA46> z8@CV<1?!&QNbGvk)x`_IfKsH)GOo21&OqTeth&0OGc*x+ADHwz1iUE9fOWxhgn9Kl zsC8_h|9e&A=?m_MlSo{@IgV{HpYX+D*@p;wmY z(;hCgaJBs`>%J22l>09`ShruZJDC&~v(&yilt)F-(`YH(l{+lS@ATip8O=iH?cDb@ ziSP8fneX(f%m}ziKGEfDsK$KlON(XBX?tqAjzSK1Xlv!=p&Cr>JQ-G{Dn zgmm?+#9J-@qFjo5W|J9Vz;9AkJ1xau1Mj&7^b=O!KEkIibOxua^iGWFTGuuDeGE15X@H8rSm7}rDEic%3@q7X*UA%()aqIf}IBuD} zuzC~?hVZ8Z@8ha{@XiHvCRpZv%vk2$6g1b%T;%wbw9I~WQ(s0Zu3?tA%Bh#RZfkG} zd>pcv#C2@5n-q}NI(ZyiT@kz;JQkc(=u=TnNY(U&?Yy$Y?(dVTH-Dr{JYrlZ$Gq7 zS||F6Yn{FFTV_KR)AOe=EMzWpZvAEMYFcfhx$OZ5ciEais-gOH_h#>YrWr4n?`mxA zm5sC)Jdsh-xfLDD^ExIPHPeQf43ZK#b%_ZYVG*Ss)2lu4kqk!iu~AM9=PrpCsA9<- z9tkDGFF{aPA4Xc=R`Uuu*W75giPyLieWZ^ppi%KDjSnnxVrYNXC$=&ngp<3Q{b&{k ze>$DVWyO}!3wg6ME@4yZp>* ze6Sv!4WC-0ZpQPO0a%(|*u=^FC}eW$ioMEbmMX)PgJW}#tjcHPDwj#Twogt^tWHf& z+@xiqg1#}C`JA$7CZc*Vts2*wjw9S?(C zsl{#4WhGVec|go~mBCLPG;?|%)6g^|(Cg&NG)ozOqLMGVG^gKolP?^~O8)B17D=v8 z7FmKeYh?L>(&vaQbJ3|aUAk0fUDk+X#h=*)PBN=4AJQjDJMqOBxtbIH) zK~Iil9ZCOo97)fL@BN#+Z91;oO2EKWX^OXms*=QUHfM7q^)ppxt6!h=1Z}FAjVw6J z;F)ZUH}$VypIV-jG*E4Fxe}FgZQz985mcs|H{`ea;TJysOM!z3LId=C%ax52C*?6b zl*4o~zJgOHeC%3SA-xG|5s&E*HM;6282&7N5wQ9nr?PliG9`7zu{IbHX%P=}k)4+j z#}ulOnhKOjQN(4eYTF%sn1{=kY5*0Fd%))h`l*9-Zn8_)uFY^g-;`{nPfVKi+%p~9 zE2ikgECac8nEom~GVFf8+XQ1JTYxL8R*^tm(&GJ0eZL1|v$F+E-`@#5{GI89c#1=y zVX_D=Bjf?_s>EaneT_HRtCZNq=vrk;sdQBW<8cBWWuw_SK?#IHN&lZ*54AdsqY5V{ ze1=(x4roBti0+Z2yka9c2~ON+ zIyO1raTmO8-mcvjP_X;+`pI|t0>W2F^}J$$Y2Kv^bKVH{R!OeFI9W~C|4JfRU|X#D zK{KlihFSzZAks{BMyuZku3&cZ8m55%6%4z1M1SDBeZ}Bx%+WkN`6vQSYN)oN3Oj*^ab;UD!=yA`UJbaQ$Hf*UB8b8N5va?a}U08 zMK{p{!zR4=dhW~Hm|Lq-m4E0f5+-KfLgW-J-zn+3BU^Z2!yC6GM9cyhH8%%>Lwwkn zl$3C>^*&YWm}El3ia@=Ic>sQg;*II#K16edx1KoeFLvbDH*o8pbU*KF;EMgM{}lm}QfX#>y()+zt{MlzlIpe7;f8I%!P2^%WBm zx=*@=yXQYC;SVNHe|!(mj}a9Vqe`xQe*=F#BMjffLljH36GF?pKq5MPFGMDteFzLP zj=ZD?9rNo3fv2aJ%?W>Ys+GTJBDOEhiB*GB!9OoIPf%ubt{k0Yd$_6P1td)g-Q)jh zenH3=*e;QE&$pe-uwJZr@_v02iWpE!$}`kj%X&E~tSxH0mh~MP!oL@s*OjH%sNzRC_jdFl>v=pb0 zeQIF+j6?Uh%)zG2nEL=7A6UOjOxfs2bd$78$J|MoXrUu=-&W76-WxW#7Idxwqf7b6 zqypc$kq|$r23;o^s#@Ygfz;3S>)_Y}%#XF4@zQb%rk*;mv}6FVt)&WWC?!&$AmWDkc}?)JQt1r@|uK-kKayMxpomoiqn$$%G?G5s1J&Pu>rd ze1EZhCxBfRG56R--?k4znfMf4zbx2vKvFB>s(#}~w*9HMfIObNby_#ALHmPhmS|GI zP#wRHj6!Tk$-c(fZ3Q(W2-ynq@JcIoGu7~m4&zVdF_56TTV1WE3xEqHF~LU_c~vYu z9n-Evz1VGQR2eFSPLfaA+1iOL@OKpaf+*fqYaro(%Q6EPTO3!#L?Ob?_^Lg+t$~2k z<9j>nN2&})3i98#=%8a4DoQL)R`_a45ZqEQe8!hqF-@H|n+9)}`OV_f7yiyFK4e&2 zPu~6_7L7x2z}So(cPZ1f7#gQ$@QJwId6DeUMx)prkQ;BFIS+CtA#ZKQ!gdcqBEf87 zXD-3AnMkp>_!TsrUX3H5bX<5GYhp-^K(7WSyE6el)g6{R_D!VOCha#7vkS@PY;F9| zyg~vtu*-Z1=$-d$&Wq4C#~Nk)Y=x>wMp>O3MSX9`C&)YZvfF_B+{M3IJqD zE+5dANN5SJ)?bz&4|Z$A15!(WmZs<61`dBu6U z0bZ+lRknGAS)(tYYrEAdrhec0rcyM`$|CQc6^O98pYe{fCOFwF);hwHzgB5K9UI!x zEdH6if!%fUL2pBd%vql9yH#PO?An%XgP8Z&_QyCYRd=^)KkzteBld(VBg70W1s-z&PS_eC(9927*4_RTQ({lz&m zf@2b$jZCyyZB*Z5LD#V8V3$hk^RmH>5KcvxJHH9XR25FqTO;I1AbVeVvkht7pQQ1L zz7q6G3fK||1k+mZ(EpRZu7D`+J(gk+xF-4m2j3w!)oUj*s1waqdb=%^<@Es4P|x&W z44@@=v;9#7Q2%b(>OR+SX%?8n6CM?rj3@CQYyJY$9KZKm<3g!+sY#ze=1PtB^|DRj zq8{sZZD(7QZf-3Fh_Ru1IArp;X&JOAgPdsnsBGLH^0^|@1Px61?MHGINp5)n7 zod*=fscEVlq_!+-rJ7oY;jNS0aGMJqwyyjfVfL$y&ztS%B13OQjMC8ohpIk<;AWey z+Gm(nz{8Y+kn`eK^rae0s#lMu!)D)tnCs_|=_qm?_9?-tud;ET4oo4AzUk>TKcJ_h05LQrT{U3eST@$l! zAWWZ^HWeHU&2C$K12AeRQ~ZtN8ZNawnt<} zj`7MdYPytgX;-?n;Jn+!+m-6M5wh(W?ip4yPw$fuuxl@F_#CPkigM=l zp-|xNwl}p~CzG57+#Va)68!Hh{Oa%}yQWyI?>a1u*yQ>xEa{%Qn`R8Ab4JYf`-$8; zGA@3*cgWhdl723#lhb*#-dLz}r2-sr4SSQ%4#^VLa$PERN@=R$z{1?>3mV85W8QvlSUy#Dg}gr7%6URif;r8kw22G+QaR!_RoYICE3ip`2sF_a7iJny%c z{!~Gwu|-Vdke;L>R}g~x2krs?-L}JG*;)Lzu8n48{tB_kDX1!)|42^biY!hq55Fb+ zGy=4HOO3W8liaRhr-l#RW0lp3Ww1ev77M4gY}+ZLbRaT5ezU5Bt(ad5zZ(>nHkV}` zFuQ>>_`GK}E!kPo1Y4T8?ArC4IH&hEd*ulPrRAncDX z{|Fhcz;H2$29`GG_jV9E3cL%3FZvbDx!>(wJy1K8Pv-!VPQ#3;vJomXQFzu&TifKM zN!DZ-tlO{ZupzOH$hcY8@?IKzynE71)sbsG)tZ+oBkOvpFi!gP`;t-J>MA&wmwL6E zPZ=S|>w}Q~>kqokm(BxrgdhNFLvJ?+{JIe3+Rzn*Pktdz^zDz$4;hB+HArf1rGr~O@#x#Uu%oZLG__K}- zT+Nl1U5ioCg|c#40$Fb8w*a^MN*Gtf(qP5>mn!YX>0V{3m$;F23=fa<8jHk_Kn`n> zuIL$;LhymQjWyM=@T%Qy2F92p=~A|1tX2eMwB){+Ub)q z%7;7H0a+FIFq3@(M*S7ZR;69o@aYVxy!4$~7l6yF1FCIulp4hz=~|EUwEfyUma2>* zbPyOE0lNa*;6tRwuobzvu&$@1LmAxD@tLtKCIhjl_1xO_!X&#oK$Gd^vw-3y2KsrU za^%yR>%OL41>lFBe)5oRFSvmR^L?jCA_=yrhZ}qAx+;?pe*qMns-2@#^*1UFFx-1bR8cXf=z*lSM(xE$F#b|!KbwND z8_#M+9>dC^SIm{k!fr={W^(abm&OtuTXoG3*x`9)wStv1e2&KG79a*eEaZg3X#4R; zL1_9%1^SCRVSf9F7zWsmnQDutsaI-OP@J(Q0&Fs5N`eVDG9an&@!*mZmoY`qHFq+L ztL34nYJ08}yV7xL9Mvu!)zAalxUEaT9D?4h)&4S(c*`nuyU|Dytf|NNBSI&dtVDD&K z*Ku;$@;JFPZkEuJwHGzhK$IUb=vt3D zKa$yx10-1pyFLDXGR~!^@IKZ&F4a7fEy%#U46Ca(<}>CYfc)&fn>_rnzd6s}7mIuD zC#gnh;)camN^d{c?NLBD*}UN37aq|mRYGfRZ2+Zemq;Ot;b{2r?(*&vC#?m8{qFeM z^SQM6rJr?noq6U*fK@l>R^G!kwpGsYec5v-xU@IJQ6 zZ+Oh$U-y}UlbX&{B~xg-&en%Y>L@&giG8!7v9oR^dOE4O%n`XlIA2BmbMgH>;zpO> zg&$hxg_Rx(e)GbXmBdtmvpiJ2$P8-9Rtwu5b@U09$!TuYk!&8}{&W9QNhohbfD6mP6uw|`csNcocAe7-D9fCM*5u!p3^#wiz(Ieak@7d zTC2OZ#zTZ3trN8rS93Ez&Jj;*tyPkS?u#IP8za`;_IMR7=Q2$Yt_IyBdC+BYy^=s} zc63JpoXgJRr=oICjq z+8%~J%v!hmudDVektUkFk~%Os-M4HyKh@|Rd{KN9L#LS*%?4PJ6mci{+?BMmLC^Qh zfN9S(n2^b{lG_Cu%O*0C#ge<_aA#xL>P^9d5H)Is9a$mM`WDa=7aEyGuNAo9KYlN+ zx<>D3;`pA3rOP`irO-yyh?N@sO>f$Be?mhE#Au2C?90zzxg6!fWnVdRQq+Hn)Et==LTflH9*YkCQq-LLBWuR?0ULwA)mUeBThU;S z!zVwK@B2FNJXs|kw@#dP*Ai^X-u#2*+RgY3wBQ5eDbvJhUCKF7=WfiIz{ThEO8p9- z=BJL>9k_y>ytx~x9=x!N?z2armbj@?%q31tsP9x+gwke*AErE7;yO@s$+E2vsrMt+k@V&04>;F*6&mD zE-1c*;AQ?&SGQ_F&)D^=Yf!u9Sy#~Hq1B`_b*o&o8UqtEZ1O|cQO_RvvW5Xe43~^6 zu{xn{l0!i4h~frsE1KXaI6tHYaB|?~3KSUmR{5Fbl&@AbQ6`+&^qO`GUNo>Dy~#|C zK#$)-&ePuznr-tYPpH|bo?w^Kv~XwsoVYos>AUcmqx`L9Wp=z={QxX*%hm00M;!P& zZV@r!oC~tIc7w;Q&@*Cq4gSr@B#g0IdVm;C`gKlGl6Y00AFad1i+~FU+y!m3(ibtj zn|OTn^{(MRxUyfEzdRXEbd%MUrmxROV)-`asBTGHO0%c?YkxFjX$I5Q7#!#I#)pRV zm-yzJN*@oQEXIhBJHL$n%I~+8zefSEnFohtFpL(?7K?ylRt!Jv zE1=_#*yCGFw~=I70@~=};y*xlqot9-WT^DWk3}kT7Tx>o6-kj$jKq!)TZaZNvxqV( zEi*h?NC^O0={B^Ts1#QS?*o3mUpxQ}P9X4SFcRK>E!6fATN9=NE}*$mHE2UzS3Vly z9V`z%>SN_d_@Mbh+{iJ=rhmbHx)DjiESTXlw5JI&n0@r@Y!73nA=DsB))OV4+5 zd!B4$XUlZAQ1*wnEGD1KXv779m4a_0XL=F#`eNaQ4#351sw~;J8QYhj3i(N))+{a=-WwkCdXRR#QXz&S)ANb6zj<>h)6xlA3V0MsG^JUW(&lw8?Yv8 zA5FptvG+|ToNv6mKNymqEwr;MuL(>grMv2?mqzU)%XBhX)xj0a^^T|o)1_s$3<{f# z4u?ZTQ{3#DaQZjDYB!}34hPB_gWr0hY|u<$dw>&;gf@IYQK5$oDfBCV~8bxz&hw)Pp3&%Xs^bFSk0| zVG54kXgIpaF3^K{ac%BpYYg-HgkIm_Vl$TN820#_$cvH5H_^)p39~m#P+llr7jBz0 zxDqXS)9Ni|*8_MjbWyNU!7@a} zn88%v{S$pD#U&VGWm*cs3YO7Bx@nwGh5`F&u-!KwZ zFu21O{+iucm#Q{cO8q7ILS$t20)}?)%t$9@kx=Y01)-zmVXN_yuAy2GVb{nJN-=bM zFZc~2{!j;Sez1^f2j_?1dOtzsQlVzvnGNRQKH`!^`TEUiE=Z_R5@Rg}!3oR5nVh`Q z`v<7QYigOn7+b730=O$YA z2SJi`xD|qPzCkP?)C>C*C`~^E@{potK-+1lSMR0d3%Iu9<|O31;n#x%4GZHKs`T;) z52D^RtA$vLzHU+f$)WwTsS?sz4fTK_8i5z=$swANOfrp&X1HAiojmL_Y73L z-)P9Bd(h-Q>T3|wsfS8W0EX4(1|NcGYo{BQj(QnbJmTxGn|Qo;Tba2~KNsEDtJd|6 zl%*!5IC?O>QlOW2Oo%u+J8{6wF4%;#p&Mxx)3$DD75jMqwa$}*-*4?IrgR;B%7W)D zo@l6~P7iUlh4!get!^6jXa^A+fU(Kt=-#$|Ugs2|6^xSlu2|>x2l=m>$a%t+@FGBa z>8xrvfStOxRZZKv^(pX3-QVENo&Dq+qu+X_41E|=(f*#4lSYohUDbJCqUA(L7jaL% zUH9hB-gmV%?Z^79Kka9y=JVvcW+(A+Rb7d!Ld(u8!?W|&3F*5#WF(k?%w?zr6C(KM z$K#fGHxLOuTWpUBj?m$abod@~f$sBx+bfjP?HMN#!#~)_5>(%De*e{N5&}u_6cOcf zaFFsXk+kS`jTq4>!V#rI(wpYGOFMS+AJ8^P_SO)x!+!KxPRx`tk0CwcbvKSXb(Ec_r2o&Dl3ntLb{0n0)7@oS;>vYOKaW9+rIWStToHOhL;l z5&jyZ(F<*-@)N5J1-`r2g?&nhYL^ahjq|&zjrxYKYqS$0GkI2!`2dkd@1vF;^S<|ZKf^33S(UxW=?AQNSX1(q8? zHxK9t;@OU!1V%KL7jm(_J#jBXJlHKCX;AJVjQ%}M*p{-0^O`Fns3b{NKbA^a;BelW zpwfz(yfqPXeBf{o1jGn~6MM?#e!1j>aa(cT&y z{1@E8{ET4LYl9SMB@k$(2mRk6HyB%@#)ExLxRR<7K_f>)ED7+8Qc&kjSeAqYQM_N! zs1PVlXv?LU%vhUL?Hd@ad-Q&CK6 zG*T5LfU+nYeza9_c*GB?Qpg-Ay6_Zvu!k9Z@lo0}w6-6wUp}wZ&#G1&S;hYMS`f{6 zhcxkmvBA-rel-3ox0Yn2bJyK4ia7OPRR_HN)F7==ccc^#6DxEX$1x@xKg-RXc)^13 zXh+_@T9@|@8!R1J1I8I8bR9Eys=!!hm4$Hvi;LC@BsY!e@1)AB4E`y6&SD&!d|f7k zo^h{B5x(_=kOr;b$=Gz!)DRp+T2C&JDev(nxW5>{iX~c5&D<_tzl1hE_`JSZeHYPP zbz4m*p`q`yt7MR)?>1TC!gZ=qm zU!GYnVGZ!qn0!L=MobLo5GZB|HSjP;zyi(YyD}*`?S3Zh2LKZwYcFWvaST1~;I<@G zkDBirpb(KO?~RLw?*n$~OuySXQNKf)N~J)^$ozczS(^>{x;Mkcgp*8>Qf$j`11n1J zS-V<-z;H@o4$=&$1ZI!Tb9aGsIG zn=;MD+9Hp7;&Q~nb#D+haRh`9WF?kw>m|F+?B^z`@ZGT(5%lVwjZcCu z>Dr?w5F`4J&@sQjB~1JO?kY7D8>1Y(Ya3+K67a)p;f0NBI>;F9aGRwFdR}1r0<$)r z0YsAP#Wzkp7)8z3=5gkdDiA*v(%lk&4w4dsvle)}#=RUv-!)WcOccHvQ;ND27#FuB zD@tKOFvR8!f-Glhjz1`3D?&vE5>CMr0pC{d-PS`=FX7mOY!iNd+WCLpoz5(5++`OR z`}cfY)Qj5-DDl$nG;#F3U4-}hbH1L0GjEfx^+?W2g{D;u(#H%Q@q?dn1#QEfgko;{3Ry!Y2~i8xIA_&^*wVY3EgWUe*yb zsFN^3<(l+#v7fLKM)nV;_4~4~${^2VxcDQUgI7L(4p&$S&U26S=)gGJ$-7d&4>ns{ zsvUEJN?Chm2QMqvc$S0e*L{JPpm5-U$7>^z`$$rMAR`NwoL8>c8B#q2-Cm0rgL4ah zT`aV|FUr(Nm$gWjk)UAtB&zJ~0o&339-_IuG{e2AFTId|@}g~`vhc;#782Kv6`S!4 zVeapwPwi624=u13PH4MB0e?9z&+l+*HU%EU4^?|2#yRxRw=;&w3T6rtqi?CJ`YR&M z8NRYeVo(PqUbKd9D{3}mQ2YyO%V{WmWJ-Zf+3Of|G83V;kbA;!LS*5l-Y%1iZ8m{G5pL{3+ z$xH0~EW%fQ<7!UZ=Z*#E} z2%Vqm-sJl+A!NDSTqyf;1eLcez6Ni>3zC&u_Mxxx_1^`8NY^bFS;9_>wb|cdIlm4^ zD)Q7kFyGC%O_$Vdvi?{ps!yZJeM^!ptQ>M+FkMq7=;;c{y*@U3qBs<_A2Dv}HBI+q z%1-Sbd%=XtbhAoj%Sb*@l4k(&I|xCCxg(75MH<6oJ>}NWtddTouDl})8dk);OM+bV zuqOM1VpIy)i)F9Wcjh_+=4K@uks9f3J-~>^oz9=*X5Z_8m)v3Z%#Y3_JxMz~Wx!3w zfNq(4hA}Um6aVvR^a63KW!-bvM5^u zJ~`_tF>89dP0%bmdt0Bx!*hDa7a$|PMQi2#3y2+J?^?jIg6qRpL#9SSVXm)7Q^2g0JXfh+}_$+a#YVc6g&@ zx%5c1=fsi!y!ro>cOJ&y5@HqBqR&HVX&ib%q4-GfOB$f=qirs~-7Zi-esDo%Mg|G- zl6&L3Q1v(@%fchM`VwLINlBLcYc`~**C2=q_&6h)nG|ZD@R+`nyLOmESDetw} zwD=~5rS+QmB=(DA>gsBB82E7EnHr@A&1Z(J?PjxoEoA_g3m-e*%F2Dwttm$o!no+& zAz{sm;f)?tQ&hYm;U?)yP9RBIuwp+=daX{d=noh1hl6NNy2nc#UC42+W+QN%bc!TLd z8MtN%6+LVN)@k`1^rz&(misW?%KU(lt5Aj=rY?UOJkP4`)UOS7Mm**HK3;Jvkm+P| zUm1NCEsczAmzC1muzZLYNSKKSEvEV=Jx3F@kD%-eWkadCy0GNWEk07!opJkt4a(R- 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 zcmV+r6z}UFiwFP!000021GQUwbJR$Z|KFdY@ZCbm}A)! z_Dh=1W692wV8Bk|n5j=W%VaLIJ1GWOLaW|HflP8K*eVe+W5umxCrcjSukuTe{Z(dp zlqSp>SgcC`hvlckqhmtxkgnK*Kd>Y%*eaL65#=n5KwRDja#^q_VL`fB#t}~f$<|SE ztLT(ih!gg=5=ZJ69N-)amUs%AfQ%O<)AVy&6wBws;d;FuaK-Q-&E~^cgXY6G=ci{E z*Jph&T`qc;#4;xZ{JDxU2<$&Fz64VN_rYq+*DTE#pJx)^ij&*~!J<*ze~TuX{{FW1u7Nml;U_HY3ueHX^&0vc*D}YL@b) z45Ba!AX+kC@wsI4^iF08Bw|aMEux&Pnu9?>XyRxQ6}(Wdw&WTdJRKe!42SIXCA+x1 zVQ1iz-TZof&3-w%IJ-KzIeX0@&s&J}`uytb^ybaqo)Gv=5Q;I?`c+C2U)a4 zETv0Y^qeqgf6U>Ka~Nyo58&`a9fGH~S-OzJeBFon0`k=V&}edvrq8pCKakHPbD$z@ zCvgEo!5DWke&~UP4)m15UgW7@e8Im+7Tm(FQVBw=DVxO)ic<;qfF`u&xz<-w#wIUF z>v%zg61iqRFP)}TAYn*X#d1~j*j>aGa)a?_AbiUazyGr61{1Ni1g3sV@Y34zR5O}^ z4i=VQO|JFfi&SdfM*1~tcUz>Z4Fc!JLlG(xG} zkepuG7g3XQByFZr)T0;7AW>4ISw530rSR*-+GW5md@M8~vFndC2bGYP* zjB}XiuCH{|7bDA)Q|^!5sqjKMaXj0Z2-|Vo(b$=K!Qi%7#GvE3lbJp8JUevwSURql z*uwEb3+uy~?a%hpiIOnw`<5J!r9T-@LopqPj?I1UjGPG%tU!vuGGy$Kdt=wP9A`9| zTHzG<)S^inqo-4-F+Qr^1=| zb}$QE*YZN)29vQj8ig}Ya?1@Lp@h9y_NU%xCS2ij3(UFp*q(~n%o5>D3QNw$Q$r_& z6~+_axB1u^c~%$%)^s$PjK`BOkd}NzaVF9AJ!dATK8)!Fw(a^;3x@H+5x0dkwr5U7 z%C((f=GyMevi#8qzxshc6M-W_*9xX%Z#St0U-rk7v4C+rdo;Btjy1CaI6~-693BQ1 z3{fe~=I+F@Mvm)^Co{)&;c+3>2qCzD7BvM@P!tTQu*5HZ6i0|gdor8YlW7PSv!rF& zp*3;CaWJtaz9Xk2A6W+k5FAqPNidyz1d7|5J2oYd@3wkj-ZKY zFcqVTI~K5sH5miXcqBDm&*B!J@d=*@VFi3-`}QOpS>yw*=Zr)PuhCFHu;A;SaLKsB zg^R$8!>NEZ`9uaYY19Hw!+Qk7*c(IVwlxyAJ>{eE7`Vq?8~Z{;{h0?RfSV)s5UXQ^ zcj$9D^5EjGoXz|yxTjNy0GUEhjwgt~U}n2wB;XGo62Vj;|0OYcj0`~(6&dwhFIa83)_b+zK7}!H7O-7m?-pTv(R>YsKFk&p=H5B ze&8a=ZD;g7Tz^OrtrjJ+P};5>*;7QT7dRn8e=>$ar_-q~ByfKZ*_E8HVxE=Qc;JRp z&l6+VC>-0~B;>w5=7nTRYzTi_(l_Zu#s_~`c2NQ1+ge?_C3)=FB0nfyz1ysX7 zA?_b!_Q{`@+;Df~%xqyJufe@t7oI$Fz>4Qua$<9S`0?Yh+Ktp!bio%A8&RiG2lVgU ze!s(>v2HHoaM0ZD*-N(dZjhshlI%I_bkIoZjV_ueh_`oH{FY}RfW1HegBQUqZH?IV zhJRlbi%-Lk1g_vwxxYp?7ZhpsaE?v;9#CeZWnELw0bft;>GwzM9|zPu(%MxP1DZdRdi5_>a184l+WiLsIFd!F#&;U!x{Y zlTLx!=}r~ABBfHeAk_{kd7o?&8`KzUX-i?=q7aM}NS?-ba+^`30K@Ljz!awUY;372 z&X+NYJ(0?s_{vvH>WWlJTT%B@@j$hZZG9uTY~7D%%r8$v!zTJ$7_C+!leoEJgmFtcuek_Phk&{tWVD@TM~~m>7_ay2j>R zjm)UKY}5>{?h)Dh7B%*R>V;6u=QP17#{|68ml|g@#IDG;a1o_mMG3teREeN_zAZud zrB{JcVp;8i)E_nhZ_vB%1*VTB{5Rt^?eKs|g*>G+^SgZ5ALUER8ya0|W;WXmxR8faI%WT0?|1)BOe2m|vkbc_c^jX%!gvNV+<4_kAEIoFvE6q}fIa*w4k+uazR7v{R#^ z-rcXtqjsvyCC`FeN|fEk<4e~0siXG;_N=0W9v;o`yQ}k4lzM4`5bst-1{~|Wpe<(~ zl(K(25q&omM>)+n-ru5&j;a3?Eq7a+_vo;FB!g8!`<2GCnVQj(8fY@1Y8lIXOxt-x zy*JuLGKFdM*EQMNCe_P9ER%V0t2Q8ODqq~OnKl+^nD4NqFV+e$R*-B$b-WsUTwCQ{=1F|RP# zOH--l2ntyNZw!J|Cb*O}bUfyUAHYXAS0d zYj)RC^?gTVo4x=%cN^L0gd%W2bNudx_YYJ!!cIqR)f*3JXEjrzY*vw!v=*k^qiwE? ze0^rYW9qb!R#atK3|a_P?O4l1yH!nmle&pJW&SdFuJlv22yF>sg9h=~xX@>Ga_GOW zciT>#FEPVfNP}y7M5!At@22itc`nI#@ z=@^naLF?vAVanPK*_L{%nsSHETeFK=$$OMvI_++WTZw16YI?)w`W+l@z1w{A?V$Os zdT4CECJ${eUvpbGv^O3mfvWV^& z>KRL3o?WAw3^P7os3T^2MZKXK>C`;lm6si;6Q{Z4s;n6Yv}@;SkIkuUeA4Df3l0TQ zBeFSt+S?Lu*mR|a<&QMN+Ns)vUu+iRDt1C{u4GZK-n}B|YsAMYJgQ zsE(SYtHY{}L4}?hrbfQIEAJ_Z_91}2o4hlF+_+2ztg^fX0+FpBO zq#bO1D^>8Ad5|g#Wo}l>pUZm=)tszW>?R0PrzMYQv`T+lD;wW7a}QmSQJKdqLi%Zd zZ%y&K9}fTC?Y?}{)hA+K`7-)Sty%oFJl6lDuY?Rf=U*2*i9(|CIY-}iG&p+tpNAiN zY%5ZZHjQt(z)e9mTfvRK_uuLXYi|5rPbBEdSMtD?XyXereFR$vb)~wQntP2qZ|c(A zGK**cuG;|Y-1TU(MY`6;Hqy#4|4QYswa4mt;HHY^x|TWm(Yl`m*xwUWO{xaizki!T zy)4piOv6+k!j(7IU>I2z7!-=?4rVeRbXv7Rvu4HaA1F!d{kY91?LM^Os9=G?Kd-1e zP(9F*v0!6Iqwy=cnTiQX9JkReGi5s|>c+1jhbnA+NvKgIMxxo-&`3LtB`U=%>2^-Z zFOvVElRdg|+(3ckefXFBP~%brp0mGPc6NjpUbR%y>hW68Eu9^-YALeRx3cQdKf7z6 z!{sW!J=|YF3tsT!RvAQZj5+9c1a196r4AF_J4rDJMU0v0 z=#N_Kz53?r?CZGkplO*m6t~?7!dS;FV*kZjSjvMbe0lb)nV~ca zENVf2h(6ZwO0sLPfl}5Bwe?Y*)d|yJm8%)D2H4Ee)%-!NR?yvMCOByST~pHA&wItw zjvjLxsIl|b_{|J&p0kGeOOHNE>=Yqq<8rGo!N-eT4gr9Fv) zD{Rk8SJY3vgE|lF@oNpDa>x#dDx+wZtrn)KyJ}^aA^GkJ_tIH|1>t^+lUwIFuwL)JR7?i<& zsh`vkqzQJ^I^m@tXfZ@vENVLHBNT*2Q|&?9BIrEC$}O`qMA17`s(nyx&Uk39D|gH- z5Z%)wlf1pWzA5uabt{C*+h6rV_aE5e=?Y`fzk%&KSW*6a8Zyq1cE8SJ7cV*|D-g<} y|5fm`{FUSx8jj90sQufKUQ72=p0nSsFE0lAWC@%bg&kE>|NjFlbMk}(H2?rSSOdQR From 90f9a6bc0ab05ce4f633a4309bde326b16a44bac Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 12 Sep 2017 10:01:03 +0200 Subject: [PATCH 018/101] 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 019/101] 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 020/101] 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 021/101] 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 022/101] 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 023/101] 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 024/101] 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 025/101] 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 026/101] 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 027/101] 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 028/101] 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 029/101] 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 030/101] 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 031/101] 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 032/101] 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 033/101] 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 034/101] 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 035/101] 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 036/101] 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 037/101] 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 038/101] 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 039/101] 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 040/101] 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 041/101] 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 042/101] 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 043/101] 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 044/101] 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 045/101] 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 046/101] 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 047/101] 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 048/101] 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 049/101] 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 050/101] 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 051/101] 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 052/101] 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 053/101] 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 054/101] 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 055/101] 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 056/101] 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 057/101] 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 058/101] 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 059/101] 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 060/101] 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 061/101] 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 062/101] 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 063/101] 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 064/101] 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 065/101] 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 066/101] 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 067/101] 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 068/101] 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 069/101] 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 070/101] 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 071/101] 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 072/101] 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 073/101] 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 074/101] 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 075/101] 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 076/101] 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 077/101] 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 078/101] 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 079/101] 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 080/101] 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 081/101] 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 082/101] 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 083/101] 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 084/101] 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 085/101] 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 086/101] 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 087/101] 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 088/101] 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 089/101] 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 090/101] 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 091/101] 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 092/101] 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 093/101] 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 094/101] 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 095/101] 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 096/101] 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 097/101] 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;}