From f2a38677fcee857c699bc2f459c997a223cd233c Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 2 Nov 2017 21:38:18 +0100 Subject: [PATCH 001/137] Bump python-miio for improved device support (#10294) * Bump python-miio for improved device support. * Requirements defines updated. --- homeassistant/components/fan/xiaomi_miio.py | 2 +- homeassistant/components/light/xiaomi_miio.py | 6 +++--- homeassistant/components/switch/xiaomi_miio.py | 10 +++++++--- homeassistant/components/vacuum/xiaomi_miio.py | 2 +- requirements_all.txt | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 3b0e0385f13..8fc77d1bf5e 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -31,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.0'] +REQUIREMENTS = ['python-miio==0.3.1'] ATTR_TEMPERATURE = 'temperature' ATTR_HUMIDITY = 'humidity' diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index b25f2745365..d7d0900ed28 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.0'] +REQUIREMENTS = ['python-miio==0.3.1'] # The light does not accept cct values < 1 CCT_MIN = 1 @@ -70,8 +70,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device = XiaomiPhilipsCeilingLamp(name, light, device_info) devices.append(device) elif device_info.model == 'philips.light.bulb': - from miio import Ceil - light = Ceil(host, token) + from miio import PhilipsBulb + light = PhilipsBulb(host, token) device = XiaomiPhilipsLightBall(name, light, device_info) devices.append(device) else: diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 1191322dce6..aaa37a24c0e 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.0'] +REQUIREMENTS = ['python-miio==0.3.1'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' @@ -68,8 +68,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): elif device_info.model in ['qmi.powerstrip.v1', 'zimi.powerstrip.v2']: - from miio import Strip - plug = Strip(host, token) + from miio import PowerStrip + plug = PowerStrip(host, token) device = XiaomiPowerStripSwitch(name, plug, device_info) devices.append(device) elif device_info.model in ['chuangmi.plug.m1', @@ -288,5 +288,9 @@ class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): else: self._state = state.is_on + self._state_attrs.update({ + ATTR_TEMPERATURE: state.temperature + }) + except DeviceException as ex: _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index ed19e220008..829d0878ffe 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.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-miio==0.3.0'] +REQUIREMENTS = ['python-miio==0.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9968ec65fc4..7ae835cb8a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -832,7 +832,7 @@ python-juicenet==0.0.5 # homeassistant.components.light.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.0 +python-miio==0.3.1 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 From 4d1909272285b1c8c133d163f781f5ee3dc49d6e Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 2 Nov 2017 22:17:44 +0100 Subject: [PATCH 002/137] pyLoad download sensor (#10089) * Create pyload.py * tabs and whitespaces removed * code style fix * code style fixes * code style fix * fixed standard import order * classname fixed * Added homeassistant/components/sensor/pyload.py * code formatting * implemented @fabaff recommendations * Update pyload.py * Use string formatting * Make host optional --- .coveragerc | 1 + homeassistant/components/sensor/pyload.py | 170 ++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 homeassistant/components/sensor/pyload.py diff --git a/.coveragerc b/.coveragerc index 5134f79297c..f8222dde899 100644 --- a/.coveragerc +++ b/.coveragerc @@ -545,6 +545,7 @@ omit = homeassistant/components/sensor/pocketcasts.py homeassistant/components/sensor/pushbullet.py homeassistant/components/sensor/pvoutput.py + homeassistant/components/sensor/pyload.py homeassistant/components/sensor/qnap.py homeassistant/components/sensor/radarr.py homeassistant/components/sensor/ripple.py diff --git a/homeassistant/components/sensor/pyload.py b/homeassistant/components/sensor/pyload.py new file mode 100644 index 00000000000..f9c6f2944c6 --- /dev/null +++ b/homeassistant/components/sensor/pyload.py @@ -0,0 +1,170 @@ +""" +Support for monitoring pyLoad. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.pyload/ +""" +import logging +from datetime import timedelta + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, + CONF_SSL, HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON, + CONF_MONITORED_VARIABLES) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'pyLoad' +DEFAULT_PORT = 8000 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) + +SENSOR_TYPES = { + 'speed': ['speed', 'Speed', 'MB/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=['speed']): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_USERNAME): cv.string, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the pyLoad sensors.""" + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + ssl = 's' if config.get(CONF_SSL) else '' + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + monitored_types = config.get(CONF_MONITORED_VARIABLES) + url = "http{}://{}:{}/api/".format(ssl, host, port) + + try: + pyloadapi = PyLoadAPI( + api_url=url, username=username, password=password) + pyloadapi.update() + except (requests.exceptions.ConnectionError, + requests.exceptions.HTTPError) as conn_err: + _LOGGER.error("Error setting up pyLoad API: %s", conn_err) + return False + + devices = [] + for ng_type in monitored_types: + new_sensor = PyLoadSensor( + api=pyloadapi, sensor_type=SENSOR_TYPES.get(ng_type), + client_name=name) + devices.append(new_sensor) + + add_devices(devices, True) + + +class PyLoadSensor(Entity): + """Representation of a pyLoad sensor.""" + + def __init__(self, api, sensor_type, client_name): + """Initialize a new pyLoad sensor.""" + self._name = '{} {}'.format(client_name, sensor_type[1]) + self.type = sensor_type[0] + self.api = api + self._state = None + self._unit_of_measurement = sensor_type[2] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @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 this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Update state of sensor.""" + try: + self.api.update() + except requests.exceptions.ConnectionError: + # Error calling the API, already logged in api.update() + return + + if self.api.status is None: + _LOGGER.debug("Update of %s requested, but no status is available", + self._name) + return + + value = self.api.status.get(self.type) + if value is None: + _LOGGER.warning("Unable to locate value for %s", self.type) + return + + if "speed" in self.type and value > 0: + # Convert download rate from Bytes/s to MBytes/s + self._state = round(value / 2**20, 2) + else: + self._state = value + + +class PyLoadAPI(object): + """Simple wrapper for pyLoad's API.""" + + def __init__(self, api_url, username=None, password=None): + """Initialize pyLoad API and set headers needed later.""" + self.api_url = api_url + self.status = None + self.headers = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} + + if username is not None and password is not None: + self.payload = {'username': username, 'password': password} + self.login = requests.post( + '{}{}'.format(api_url, 'login'), data=self.payload, timeout=5) + self.update() + + def post(self, method, params=None): + """Send a POST request and return the response as a dict.""" + payload = {'method': method} + + if params: + payload['params'] = params + + try: + response = requests.post( + '{}{}'.format(self.api_url, 'statusServer'), json=payload, + cookies=self.login.cookies, headers=self.headers, timeout=5) + response.raise_for_status() + _LOGGER.debug("JSON Response: %s", response.json()) + return response.json() + + except requests.exceptions.ConnectionError as conn_exc: + _LOGGER.error("Failed to update pyLoad status. Error: %s", + conn_exc) + raise + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update cached response.""" + try: + self.status = self.post('speed') + except requests.exceptions.ConnectionError: + # Failed to update status - exception already logged in self.post + raise From 47d9403e3a40b7c65898604265d7d26b0773b0ee Mon Sep 17 00:00:00 2001 From: NovapaX Date: Fri, 3 Nov 2017 04:55:09 +0100 Subject: [PATCH 003/137] update mask-icon to a working mask-icon.svg (#10290) * update mask-icon to favicon.svg * change name of icon to mask-icon.svg --- homeassistant/components/frontend/templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 41d17347de8..c941fbc15ae 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -8,7 +8,7 @@ - + {% if not dev_mode %} {% for panel in panels.values() -%} From 8f774e9c531555cdf31d2ff5f955a9e27ad85404 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Nov 2017 22:18:10 -0700 Subject: [PATCH 004/137] Cleanup Xiaomi Aqara (#10302) --- homeassistant/components/xiaomi_aqara.py | 230 +++++++++++------------ 1 file changed, 108 insertions(+), 122 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index de4fad503c9..26950322857 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -1,4 +1,5 @@ """Support for Xiaomi Gateways.""" +import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -17,6 +18,7 @@ ATTR_DEVICE_ID = 'device_id' CONF_DISCOVERY_RETRY = 'discovery_retry' CONF_GATEWAYS = 'gateways' CONF_INTERFACE = 'interface' +CONF_KEY = 'key' DOMAIN = 'xiaomi_aqara' PY_XIAOMI_GATEWAY = "xiaomi_gw" @@ -25,76 +27,57 @@ SERVICE_STOP_RINGTONE = 'stop_ringtone' SERVICE_ADD_DEVICE = 'add_device' SERVICE_REMOVE_DEVICE = 'remove_device' -XIAOMI_AQARA_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_GW_MAC): vol.All(cv.string, - vol.Any(vol.Length(min=12, max=12), - vol.Length(min=17, max=17))) + +GW_MAC = vol.All( + cv.string, + lambda value: value.replace(':', '').lower(), + vol.Length(min=12, max=12) +) + + +SERVICE_SCHEMA_PLAY_RINGTONE = vol.Schema({ + vol.Required(ATTR_RINGTONE_ID): + vol.All(vol.Coerce(int), vol.NotIn([9, 14, 15, 16, 17, 18, 19])), + vol.Optional(ATTR_RINGTONE_VOL): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)) }) -SERVICE_SCHEMA_PLAY_RINGTONE = XIAOMI_AQARA_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_RINGTONE_ID): vol.Coerce(int), - vol.Optional(ATTR_RINGTONE_VOL): vol.All(vol.Coerce(int), - vol.Clamp(min=0, max=100)) +SERVICE_SCHEMA_REMOVE_DEVICE = vol.Schema({ + vol.Required(ATTR_DEVICE_ID): + vol.All(cv.string, vol.Length(min=14, max=14)) }) -SERVICE_SCHEMA_REMOVE_DEVICE = XIAOMI_AQARA_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_DEVICE_ID): vol.All(cv.string, - vol.Length(min=14, max=14)) + +GATEWAY_CONFIG = vol.Schema({ + vol.Optional(CONF_MAC): GW_MAC, + vol.Optional(CONF_KEY, default=None): + vol.All(cv.string, vol.Length(min=16, max=16)), + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=9898): cv.port, }) -SERVICE_TO_METHOD = { - SERVICE_PLAY_RINGTONE: {'method': 'play_ringtone_service', - 'schema': SERVICE_SCHEMA_PLAY_RINGTONE}, - SERVICE_STOP_RINGTONE: {'method': 'stop_ringtone_service'}, - SERVICE_ADD_DEVICE: {'method': 'add_device_service'}, - SERVICE_REMOVE_DEVICE: {'method': 'remove_device_service', - 'schema': SERVICE_SCHEMA_REMOVE_DEVICE}, -} + +def _fix_conf_defaults(config): + """Update some config defaults.""" + config['sid'] = config.pop(CONF_MAC, None) + + if config.get(CONF_KEY) is None: + _LOGGER.warning( + 'Key is not provided for gateway %s. Controlling the gateway ' + 'will not be possible.', config['sid']) + + if config.get(CONF_HOST) is None: + config.pop(CONF_PORT) + + return config -def _validate_conf(config): - """Validate a list of devices definitions.""" - res_config = [] - for gw_conf in config: - for _conf in gw_conf.keys(): - if _conf not in [CONF_MAC, CONF_HOST, CONF_PORT, 'key']: - raise vol.Invalid('{} is not a valid config parameter'. - format(_conf)) - - res_gw_conf = {'sid': gw_conf.get(CONF_MAC)} - if res_gw_conf['sid'] is not None: - res_gw_conf['sid'] = res_gw_conf['sid'].replace(":", "").lower() - if len(res_gw_conf['sid']) != 12: - raise vol.Invalid('Invalid mac address', gw_conf.get(CONF_MAC)) - key = gw_conf.get('key') - - if key is None: - _LOGGER.warning( - 'Gateway Key is not provided.' - ' Controlling gateway device will not be possible.') - elif len(key) != 16: - raise vol.Invalid('Invalid key {}.' - ' Key must be 16 characters'.format(key)) - res_gw_conf['key'] = key - - host = gw_conf.get(CONF_HOST) - if host is not None: - res_gw_conf[CONF_HOST] = host - res_gw_conf['port'] = gw_conf.get(CONF_PORT, 9898) - - _LOGGER.warning( - 'Static address (%s:%s) of the gateway provided. ' - 'Discovery of this host will be skipped.', - res_gw_conf[CONF_HOST], res_gw_conf[CONF_PORT]) - - res_config.append(res_gw_conf) - return res_config - +DEFAULT_GATEWAY_CONFIG = [{CONF_MAC: None, CONF_KEY: None}] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_GATEWAYS, default=[{CONF_MAC: None, "key": None}]): - vol.All(cv.ensure_list, _validate_conf), + vol.Optional(CONF_GATEWAYS, default=DEFAULT_GATEWAY_CONFIG): + vol.All(cv.ensure_list, [GATEWAY_CONFIG], [_fix_conf_defaults]), vol.Optional(CONF_INTERFACE, default='any'): cv.string, vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int }) @@ -113,30 +96,30 @@ def setup(hass, config): interface = config[DOMAIN][CONF_INTERFACE] discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + @asyncio.coroutine 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, - interface) + xiaomi = hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway( + hass.add_job, gateways, interface) _LOGGER.debug("Expecting %s gateways", len(gateways)) for k in range(discovery_retry): _LOGGER.info('Discovering Xiaomi Gateways (Try %s)', k + 1) - hass.data[PY_XIAOMI_GATEWAY].discover_gateways() - if len(hass.data[PY_XIAOMI_GATEWAY].gateways) >= len(gateways): + xiaomi.discover_gateways() + if len(xiaomi.gateways) >= len(gateways): break - if not hass.data[PY_XIAOMI_GATEWAY].gateways: + if not xiaomi.gateways: _LOGGER.error("No gateway discovered") return False - hass.data[PY_XIAOMI_GATEWAY].listen() + xiaomi.listen() _LOGGER.debug("Gateways discovered. Listening for broadcasts") for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover']: @@ -145,81 +128,60 @@ def setup(hass, config): def stop_xiaomi(event): """Stop Xiaomi Socket.""" _LOGGER.info("Shutting down Xiaomi Hub.") - hass.data[PY_XIAOMI_GATEWAY].stop_listen() + xiaomi.stop_listen() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_xiaomi) - # pylint: disable=unused-variable def play_ringtone_service(call): """Service to play ringtone through Gateway.""" - ring_id = int(call.data.get(ATTR_RINGTONE_ID)) - gw_sid = call.data.get(ATTR_GW_MAC).replace(":", "").lower() + ring_id = call.data.get(ATTR_RINGTONE_ID) + gateway = call.data.get(ATTR_GW_MAC) - if ring_id in [9, 14-19]: - _LOGGER.error('Specified mid: %s is not defined in gateway.', - ring_id) - return + kwargs = {'mid': ring_id} ring_vol = call.data.get(ATTR_RINGTONE_VOL) - if ring_vol is None: - ringtone = {'mid': ring_id} - else: - ringtone = {'mid': ring_id, 'vol': int(ring_vol)} + if ring_vol is not None: + kwargs['vol'] = ring_vol - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - if gateway.sid == gw_sid: - gateway.write_to_hub(gateway.sid, **ringtone) - break - else: - _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + gateway.write_to_hub(gateway.sid, **kwargs) - # pylint: disable=unused-variable def stop_ringtone_service(call): """Service to stop playing ringtone on Gateway.""" - gw_sid = call.data.get(ATTR_GW_MAC).replace(":", "").lower() - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - if gateway.sid == gw_sid: - ringtone = {'mid': 10000} - gateway.write_to_hub(gateway.sid, **ringtone) - break - else: - _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + gateway = call.data.get(ATTR_GW_MAC) + gateway.write_to_hub(gateway.sid, mid=10000) - # pylint: disable=unused-variable def add_device_service(call): """Service to add a new sub-device within the next 30 seconds.""" - gw_sid = call.data.get(ATTR_GW_MAC).replace(":", "").lower() - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - if gateway.sid == gw_sid: - join_permission = {'join_permission': 'yes'} - gateway.write_to_hub(gateway.sid, **join_permission) - hass.components.persistent_notification.async_create( - 'Join permission enabled for 30 seconds! ' - 'Please press the pairing button of the new device once.', - title='Xiaomi Aqara Gateway') - break - else: - _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + gateway = call.data.get(ATTR_GW_MAC) + gateway.write_to_hub(gateway.sid, join_permission='yes') + hass.components.persistent_notification.async_create( + 'Join permission enabled for 30 seconds! ' + 'Please press the pairing button of the new device once.', + title='Xiaomi Aqara Gateway') - # pylint: disable=unused-variable def remove_device_service(call): """Service to remove a sub-device from the gateway.""" device_id = call.data.get(ATTR_DEVICE_ID) - gw_sid = call.data.get(ATTR_GW_MAC).replace(":", "").lower() - remove_device = {'remove_device': device_id} - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - if gateway.sid == gw_sid: - gateway.write_to_hub(gateway.sid, **remove_device) - break - else: - _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + gateway = call.data.get(ATTR_GW_MAC) + gateway.write_to_hub(gateway.sid, remove_device=device_id) - for xiaomi_aqara_service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[xiaomi_aqara_service].get( - 'schema', XIAOMI_AQARA_SERVICE_SCHEMA) - service_handler = SERVICE_TO_METHOD[xiaomi_aqara_service].get('method') - hass.services.async_register( - DOMAIN, xiaomi_aqara_service, service_handler, - description=None, schema=schema) + gateway_only_schema = _add_gateway_to_schema(xiaomi, vol.Schema({})) + + hass.services.async_register( + DOMAIN, SERVICE_PLAY_RINGTONE, play_ringtone_service, + schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_PLAY_RINGTONE)) + + hass.services.async_register( + DOMAIN, SERVICE_STOP_RINGTONE, stop_ringtone_service, + schema=gateway_only_schema) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_DEVICE, add_device_service, + schema=gateway_only_schema) + + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_DEVICE, remove_device_service, + schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_REMOVE_DEVICE)) return True @@ -276,3 +238,27 @@ class XiaomiDevice(Entity): def parse_data(self, data): """Parse data sent by gateway.""" raise NotImplementedError() + + +def _add_gateway_to_schema(xiaomi, schema): + """Extend a voluptuous schema with a gateway validator.""" + def gateway(sid): + """Convert sid to a gateway.""" + sid = str(sid).replace(':', '').lower() + + for gateway in xiaomi.gateways.values(): + if gateway.sid == sid: + return gateway + + raise vol.Invalid('Unknown gateway sid {}'.format(sid)) + + gateways = list(xiaomi.gateways.values()) + kwargs = {} + + # If the user has only 1 gateway, make it the default for services. + if len(gateways) == 1: + kwargs['default'] = gateways[0] + + return schema.extend({ + vol.Required(ATTR_GW_MAC, **kwargs): gateway + }) From 2598770b49ecbe7d9cfe800721560efe08ac177b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Nov 2017 22:51:53 -0700 Subject: [PATCH 005/137] Update frontend --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index bedcd0bd7ae..d354557fa0f 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171102.0'] +REQUIREMENTS = ['home-assistant-frontend==20171103.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api'] diff --git a/requirements_all.txt b/requirements_all.txt index 7ae835cb8a4..e617b37fe1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,7 +330,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171102.0 +home-assistant-frontend==20171103.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1b2624cd66..62aaf4c3b5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171102.0 +home-assistant-frontend==20171103.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From a4f7828363d7327039d41646ccee0c4be9b24f25 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Nov 2017 23:30:05 -0700 Subject: [PATCH 006/137] Cloud: Authenticate with id token (#10304) --- homeassistant/components/cloud/iot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 92b517b570c..1bb6668e0cc 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -62,7 +62,7 @@ class CloudIoT: self.client = client = yield from session.ws_connect( self.cloud.relayer, headers={ hdrs.AUTHORIZATION: - 'Bearer {}'.format(self.cloud.access_token) + 'Bearer {}'.format(self.cloud.id_token) }) self.tries = 0 From 23809bff6437b90f2301be5da105d39670433256 Mon Sep 17 00:00:00 2001 From: Heiko Thiery Date: Fri, 3 Nov 2017 08:59:11 +0100 Subject: [PATCH 007/137] Add LaCrosse sensor platform (#10195) * Initial commit of LaCrosse sensor component Signed-off-by: Heiko Thiery * fix review comments from houndci-bot Signed-off-by: Heiko Thiery * fix review comments from houndci-bot Signed-off-by: Heiko Thiery * add pylacrosse version to REQUIREMENTS Signed-off-by: Heiko Thiery * add lacrosse to .coveragerc Signed-off-by: Heiko Thiery * import 3rd party libraries inside methods Signed-off-by: Heiko Thiery * add pylacrosse to requirements_all.txt Signed-off-by: Heiko Thiery * add missing docstring Signed-off-by: Heiko Thiery * fix pylint warning Signed-off-by: Heiko Thiery * fix pylint warning Signed-off-by: Heiko Thiery * fix pylint warnings Signed-off-by: Heiko Thiery * remove too many blank lines Signed-off-by: Heiko Thiery * some minor cleanup Signed-off-by: Heiko Thiery * change to single quote Signed-off-by: Heiko Thiery * incorporate review comments Signed-off-by: Heiko Thiery * remove type check as validation only allows TYPES Signed-off-by: Heiko Thiery * Adjust log level and update ordering --- .coveragerc | 1 + homeassistant/components/sensor/lacrosse.py | 217 ++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 221 insertions(+) create mode 100755 homeassistant/components/sensor/lacrosse.py diff --git a/.coveragerc b/.coveragerc index f8222dde899..3bfd983dc30 100644 --- a/.coveragerc +++ b/.coveragerc @@ -517,6 +517,7 @@ omit = homeassistant/components/sensor/influxdb.py homeassistant/components/sensor/irish_rail_transport.py homeassistant/components/sensor/kwb.py + homeassistant/components/sensor/lacrosse.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py diff --git a/homeassistant/components/sensor/lacrosse.py b/homeassistant/components/sensor/lacrosse.py new file mode 100755 index 00000000000..28cba7da0b4 --- /dev/null +++ b/homeassistant/components/sensor/lacrosse.py @@ -0,0 +1,217 @@ +""" +Support for LaCrosse sensor components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.lacrosse/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.components.sensor import (ENTITY_ID_FORMAT, PLATFORM_SCHEMA) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, CONF_DEVICE, CONF_NAME, CONF_ID, + CONF_SENSORS, CONF_TYPE, TEMP_CELSIUS) +from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +REQUIREMENTS = ['pylacrosse==0.2.7'] + +_LOGGER = logging.getLogger(__name__) + +CONF_BAUD = 'baud' +CONF_EXPIRE_AFTER = 'expire_after' + +DEFAULT_DEVICE = '/dev/ttyUSB0' +DEFAULT_BAUD = '57600' +DEFAULT_EXPIRE_AFTER = 300 + +TYPES = ['battery', 'humidity', 'temperature'] + +SENSOR_SCHEMA = vol.Schema({ + vol.Required(CONF_ID): cv.positive_int, + vol.Required(CONF_TYPE): vol.In(TYPES), + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), + vol.Optional(CONF_BAUD, default=DEFAULT_BAUD): cv.string, + vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the LaCrosse sensors.""" + import pylacrosse + from serial import SerialException + + usb_device = config.get(CONF_DEVICE) + baud = int(config.get(CONF_BAUD)) + expire_after = config.get(CONF_EXPIRE_AFTER) + + _LOGGER.debug("%s %s", usb_device, baud) + + try: + lacrosse = pylacrosse.LaCrosse(usb_device, baud) + lacrosse.open() + except SerialException as exc: + _LOGGER.warning("Unable to open serial port: %s", exc) + return False + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lacrosse.close) + + sensors = [] + for device, device_config in config[CONF_SENSORS].items(): + _LOGGER.debug("%s %s", device, device_config) + + typ = device_config.get(CONF_TYPE) + sensor_class = TYPE_CLASSES[typ] + name = device_config.get(CONF_NAME, device) + + sensors.append( + sensor_class( + hass, lacrosse, device, name, expire_after, device_config + ) + ) + + add_devices(sensors) + + +class LaCrosseSensor(Entity): + """Implementation of a Lacrosse sensor.""" + + _temperature = None + _humidity = None + _low_battery = None + _new_battery = None + + def __init__(self, hass, lacrosse, device_id, name, expire_after, config): + """Initialize the sensor.""" + self.hass = hass + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, device_id, hass=hass) + self._config = config + self._name = name + self._value = None + self._expire_after = expire_after + self._expiration_trigger = None + + lacrosse.register_callback( + int(self._config['id']), self._callback_lacrosse, None) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + def update(self, *args): + """Get the latest data.""" + pass + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + 'low_battery': self._low_battery, + 'new_battery': self._new_battery, + } + return attributes + + def _callback_lacrosse(self, lacrosse_sensor, user_data): + """Callback function that is called from pylacrosse with new values.""" + if self._expire_after is not None and self._expire_after > 0: + # Reset old trigger + if self._expiration_trigger: + self._expiration_trigger() + self._expiration_trigger = None + + # Set new trigger + expiration_at = ( + dt_util.utcnow() + timedelta(seconds=self._expire_after)) + + self._expiration_trigger = async_track_point_in_utc_time( + self.hass, self.value_is_expired, expiration_at) + + self._temperature = round(lacrosse_sensor.temperature * 2) / 2 + self._humidity = lacrosse_sensor.humidity + self._low_battery = lacrosse_sensor.low_battery + self._new_battery = lacrosse_sensor.new_battery + + @callback + def value_is_expired(self, *_): + """Triggered when value is expired.""" + self._expiration_trigger = None + self._value = None + self.async_schedule_update_ha_state() + + +class LaCrosseTemperature(LaCrosseSensor): + """Implementation of a Lacrosse temperature sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def state(self): + """Return the state of the sensor.""" + return self._temperature + + +class LaCrosseHumidity(LaCrosseSensor): + """Implementation of a Lacrosse humidity sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return '%' + + @property + def state(self): + """Return the state of the sensor.""" + return self._humidity + + @property + def icon(self): + """Icon to use in the frontend.""" + return 'mdi:water-percent' + + +class LaCrosseBattery(LaCrosseSensor): + """Implementation of a Lacrosse battery sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + if self._low_battery is None: + state = None + elif self._low_battery is True: + state = 'low' + else: + state = 'ok' + return state + + @property + def icon(self): + """Icon to use in the frontend.""" + if self._low_battery is None: + icon = 'mdi:battery-unknown' + elif self._low_battery is True: + icon = 'mdi:battery-alert' + else: + icon = 'mdi:battery' + return icon + + +TYPE_CLASSES = { + 'temperature': LaCrosseTemperature, + 'humidity': LaCrosseHumidity, + 'battery': LaCrosseBattery +} diff --git a/requirements_all.txt b/requirements_all.txt index e617b37fe1e..c65927ce3eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -695,6 +695,9 @@ pykira==0.1.1 # homeassistant.components.sensor.kwb pykwb==0.0.8 +# homeassistant.components.sensor.lacrosse +pylacrosse==0.2.7 + # homeassistant.components.sensor.lastfm pylast==2.0.0 From a943b207ba78040dd8516fa0aa15351ad2908ce4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Nov 2017 02:28:31 -0700 Subject: [PATCH 008/137] Fix panel_custom (#10303) * Fix panel_custom * lint --- homeassistant/components/panel_custom.py | 2 +- tests/components/test_panel_custom.py | 38 +++++++++++------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/panel_custom.py b/homeassistant/components/panel_custom.py index 0c857f1abd4..473d44f3b55 100644 --- a/homeassistant/components/panel_custom.py +++ b/homeassistant/components/panel_custom.py @@ -61,7 +61,7 @@ def async_setup(hass, config): name, panel_path, sidebar_title=panel.get(CONF_SIDEBAR_TITLE), sidebar_icon=panel.get(CONF_SIDEBAR_ICON), - url_path=panel.get(CONF_URL_PATH), + frontend_url_path=panel.get(CONF_URL_PATH), config=panel.get(CONF_CONFIG), ) diff --git a/tests/components/test_panel_custom.py b/tests/components/test_panel_custom.py index b032d91a553..d33221da2a7 100644 --- a/tests/components/test_panel_custom.py +++ b/tests/components/test_panel_custom.py @@ -5,21 +5,19 @@ from unittest.mock import Mock, patch import pytest from homeassistant import setup +from homeassistant.components import frontend -from tests.common import mock_coro, mock_component +from tests.common import mock_component -@pytest.fixture -def mock_register(hass): - """Mock the frontend component being loaded and yield register method.""" +@pytest.fixture(autouse=True) +def mock_frontend_loaded(hass): + """Mock frontend is loaded.""" mock_component(hass, 'frontend') - with patch('homeassistant.components.frontend.async_register_panel', - return_value=mock_coro()) as mock_register: - yield mock_register @asyncio.coroutine -def test_webcomponent_custom_path_not_found(hass, mock_register): +def test_webcomponent_custom_path_not_found(hass): """Test if a web component is found in config panels dir.""" filename = 'mock.file' @@ -39,11 +37,11 @@ def test_webcomponent_custom_path_not_found(hass, mock_register): hass, 'panel_custom', config ) assert not result - assert not mock_register.called + assert len(hass.data.get(frontend.DATA_PANELS, {})) == 0 @asyncio.coroutine -def test_webcomponent_custom_path(hass, mock_register): +def test_webcomponent_custom_path(hass): """Test if a web component is found in config panels dir.""" filename = 'mock.file' @@ -65,15 +63,15 @@ def test_webcomponent_custom_path(hass, mock_register): ) assert result - assert mock_register.called + panels = hass.data.get(frontend.DATA_PANELS, []) - args = mock_register.mock_calls[0][1] - assert args == (hass, 'todomvc', filename) + assert len(panels) == 1 + assert 'nice_url' in panels - kwargs = mock_register.mock_calls[0][2] - assert kwargs == { - 'config': 5, - 'url_path': 'nice_url', - 'sidebar_icon': 'mdi:iconicon', - 'sidebar_title': 'Sidebar Title' - } + panel = panels['nice_url'] + + assert panel.config == 5 + assert panel.frontend_url_path == 'nice_url' + assert panel.sidebar_icon == 'mdi:iconicon' + assert panel.sidebar_title == 'Sidebar Title' + assert panel.path == filename From 9b8c64c8b6954f060ef5b340f1fca94addaed6c4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 3 Nov 2017 13:51:17 +0100 Subject: [PATCH 009/137] Upgrade credstash to 1.14.0 (#10310) --- homeassistant/scripts/credstash.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/scripts/credstash.py b/homeassistant/scripts/credstash.py index c54de236070..12516e55c7d 100644 --- a/homeassistant/scripts/credstash.py +++ b/homeassistant/scripts/credstash.py @@ -4,7 +4,7 @@ import getpass from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['credstash==1.13.3', 'botocore==1.7.34'] +REQUIREMENTS = ['credstash==1.14.0', 'botocore==1.7.34'] def run(args): diff --git a/requirements_all.txt b/requirements_all.txt index c65927ce3eb..ae5ebab2133 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -170,7 +170,7 @@ colorlog==3.0.1 concord232==0.14 # homeassistant.scripts.credstash -# credstash==1.13.3 +# credstash==1.14.0 # homeassistant.components.sensor.crimereports crimereports==1.0.0 From 4e8e04fe6635b3adb9bdc041c41913f8131eab27 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Nov 2017 06:19:36 -0700 Subject: [PATCH 010/137] Clean up core (#10305) * Clean up core * Lint * Fix tests * Address comment * Update entity.py * romve test for forward update to async_update * fix lint --- homeassistant/core.py | 17 +++---- homeassistant/helpers/entity.py | 10 +---- homeassistant/helpers/template.py | 29 +++++++----- .../alarm_control_panel/test_manual.py | 45 ++++++++----------- .../alarm_control_panel/test_manual_mqtt.py | 25 ++++------- tests/helpers/test_entity.py | 16 ------- tests/test_core.py | 12 ----- 7 files changed, 52 insertions(+), 102 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 31bb281aeaa..30be92af153 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -387,7 +387,7 @@ class EventBus(object): @callback def async_fire(self, event_type: str, event_data=None, - origin=EventOrigin.local, wait=False): + origin=EventOrigin.local): """Fire an event. This method must be run in the event loop. @@ -395,8 +395,10 @@ class EventBus(object): listeners = self._listeners.get(event_type, []) # EVENT_HOMEASSISTANT_CLOSE should go only to his listeners - if event_type != EVENT_HOMEASSISTANT_CLOSE: - listeners = self._listeners.get(MATCH_ALL, []) + listeners + match_all_listeners = self._listeners.get(MATCH_ALL) + if (match_all_listeners is not None and + event_type != EVENT_HOMEASSISTANT_CLOSE): + listeners = match_all_listeners + listeners event = Event(event_type, event_data, origin) @@ -673,15 +675,6 @@ class StateMachine(object): state_obj = self.get(entity_id) return state_obj is not None and state_obj.state == state - def is_state_attr(self, entity_id, name, value): - """Test if entity exists and has a state attribute set to value. - - Async friendly. - """ - state_obj = self.get(entity_id) - return state_obj is not None and \ - state_obj.attributes.get(name, None) == value - def remove(self, entity_id): """Remove the state of an entity. diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index da82fc9202f..8e032bc48a1 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -168,15 +168,9 @@ class Entity(object): def update(self): """Retrieve latest state. - When not implemented, will forward call to async version if available. + For asyncio use coroutine async_update. """ - async_update = getattr(self, 'async_update', None) - - if async_update is None: - return - - # pylint: disable=not-callable - run_coroutine_threadsafe(async_update(), self.hass.loop).result() + pass # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6f83688623a..bf1b88e1c3f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -107,7 +107,8 @@ class Template(object): This method must be run in the event loop. """ - self._ensure_compiled() + if self._compiled is None: + self._ensure_compiled() if variables is not None: kwargs.update(variables) @@ -135,7 +136,8 @@ class Template(object): This method must be run in the event loop. """ - self._ensure_compiled() + if self._compiled is None: + self._ensure_compiled() variables = { 'value': value @@ -154,20 +156,17 @@ class Template(object): def _ensure_compiled(self): """Bind a template to a specific hass instance.""" - if self._compiled is not None: - return - self.ensure_valid() assert self.hass is not None, 'hass variable not set on template' - location_methods = LocationMethods(self.hass) + template_methods = TemplateMethods(self.hass) global_vars = ENV.make_globals({ - 'closest': location_methods.closest, - 'distance': location_methods.distance, + 'closest': template_methods.closest, + 'distance': template_methods.distance, 'is_state': self.hass.states.is_state, - 'is_state_attr': self.hass.states.is_state_attr, + 'is_state_attr': template_methods.is_state_attr, 'states': AllStates(self.hass), }) @@ -272,11 +271,11 @@ def _wrap_state(state): return None if state is None else TemplateState(state) -class LocationMethods(object): - """Class to expose distance helpers to templates.""" +class TemplateMethods(object): + """Class to expose helpers to templates.""" def __init__(self, hass): - """Initialize the distance helpers.""" + """Initialize the helpers.""" self._hass = hass def closest(self, *args): @@ -390,6 +389,12 @@ class LocationMethods(object): return self._hass.config.units.length( loc_util.distance(*locations[0] + locations[1]), 'm') + def is_state_attr(self, entity_id, name, value): + """Test if a state is a specific attribute.""" + state_obj = self._hass.states.get(entity_id) + return state_obj is not None and \ + state_obj.attributes.get(name) == value + def _resolve_state(self, entity_id_or_state): """Return state or entity_id if given.""" if isinstance(entity_id_or_state, State): diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index b5af01584d3..1b10b942281 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -72,10 +72,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_HOME)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_HOME future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -83,8 +81,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_ARMED_HOME, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_HOME def test_arm_home_with_invalid_code(self): """Attempt to arm home without a valid code.""" @@ -155,10 +153,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_AWAY)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_AWAY future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -166,8 +162,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_ARMED_AWAY, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_AWAY def test_arm_away_with_invalid_code(self): """Attempt to arm away without a valid code.""" @@ -238,10 +234,9 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_NIGHT)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == \ + STATE_ALARM_ARMED_NIGHT future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -249,8 +244,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_ARMED_NIGHT, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_NIGHT def test_arm_night_with_invalid_code(self): """Attempt to night home without a valid code.""" @@ -329,10 +324,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_TRIGGERED)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -340,8 +333,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_TRIGGERED, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED future = dt_util.utcnow() + timedelta(seconds=5) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -349,8 +342,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_DISARMED, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_DISARMED def test_armed_home_with_specific_pending(self): """Test arm home method.""" diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py index 5210c616f9c..e56b6865e6e 100644 --- a/tests/components/alarm_control_panel/test_manual_mqtt.py +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -100,10 +100,8 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_HOME)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_HOME future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' @@ -189,10 +187,8 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_AWAY)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_AWAY future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' @@ -278,10 +274,9 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_NIGHT)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == \ + STATE_ALARM_ARMED_NIGHT future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' @@ -375,10 +370,8 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_TRIGGERED)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index d7f518f489e..a4c8b03daa0 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -100,22 +100,6 @@ class TestHelpersEntity(object): fmt, 'overwrite hidden true', hass=self.hass) == 'test.overwrite_hidden_true_2' - def test_update_calls_async_update_if_available(self): - """Test async update getting called.""" - async_update = [] - - class AsyncEntity(entity.Entity): - hass = self.hass - entity_id = 'sensor.test' - - @asyncio.coroutine - def async_update(self): - async_update.append([1]) - - ent = AsyncEntity() - ent.update() - assert len(async_update) == 1 - def test_device_class(self): """Test device class attribute.""" state = self.hass.states.get(self.entity.entity_id) diff --git a/tests/test_core.py b/tests/test_core.py index c3fea749f5d..09ddf721628 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -495,18 +495,6 @@ class TestStateMachine(unittest.TestCase): self.assertFalse(self.states.is_state('light.Bowl', 'off')) self.assertFalse(self.states.is_state('light.Non_existing', 'on')) - def test_is_state_attr(self): - """Test is_state_attr method.""" - self.states.set("light.Bowl", "on", {"brightness": 100}) - self.assertTrue( - self.states.is_state_attr('light.Bowl', 'brightness', 100)) - self.assertFalse( - self.states.is_state_attr('light.Bowl', 'friendly_name', 200)) - self.assertFalse( - self.states.is_state_attr('light.Bowl', 'friendly_name', 'Bowl')) - self.assertFalse( - self.states.is_state_attr('light.Non_existing', 'brightness', 100)) - def test_entity_ids(self): """Test get_entity_ids method.""" ent_ids = self.states.entity_ids() From 1347c3191fed0636e92bc9bbc357d73761be89cb Mon Sep 17 00:00:00 2001 From: Hugo Dupras Date: Fri, 3 Nov 2017 14:25:26 +0100 Subject: [PATCH 011/137] Refactor Neato botvac components as a vacuum (#9946) * Refactor Neato botvac components as a vacuum A switch is still use to enable/disable the schedule Signed-off-by: Hugo D. (jabesq) * CI Hound fixes * Fix lint errors Signed-off-by: Hugo D. (jabesq) * [Neato vacumm] Add sensor attributes to vacuum Signed-off-by: Hugo D. (jabesq) * Remove line breaks and fix docstring * PR fixes --- homeassistant/components/neato.py | 2 +- homeassistant/components/sensor/neato.py | 174 ------------------ homeassistant/components/switch/neato.py | 28 +-- homeassistant/components/vacuum/neato.py | 214 +++++++++++++++++++++++ 4 files changed, 219 insertions(+), 199 deletions(-) delete mode 100644 homeassistant/components/sensor/neato.py create mode 100644 homeassistant/components/vacuum/neato.py diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 2401bc6604f..e10878833e4 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -90,7 +90,7 @@ def setup(hass, config): _LOGGER.debug("Failed to login to Neato API") return False hub.update_robots() - for component in ('camera', 'sensor', 'switch'): + for component in ('camera', 'vacuum', 'switch'): discovery.load_platform(hass, component, DOMAIN, {}, config) return True diff --git a/homeassistant/components/sensor/neato.py b/homeassistant/components/sensor/neato.py deleted file mode 100644 index 5179816eb35..00000000000 --- a/homeassistant/components/sensor/neato.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Support for Neato Connected Vaccums sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.neato/ -""" -import logging -import requests -from homeassistant.helpers.entity import Entity -from homeassistant.components.neato import ( - NEATO_ROBOTS, NEATO_LOGIN, NEATO_MAP_DATA, ACTION, ERRORS, MODE, ALERTS) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['neato'] - -SENSOR_TYPE_STATUS = 'status' -SENSOR_TYPE_BATTERY = 'battery' - -SENSOR_TYPES = { - SENSOR_TYPE_STATUS: ['Status'], - SENSOR_TYPE_BATTERY: ['Battery'] -} - -ATTR_CLEAN_START = 'clean_start' -ATTR_CLEAN_STOP = 'clean_stop' -ATTR_CLEAN_AREA = 'clean_area' -ATTR_CLEAN_BATTERY_START = 'battery_level_at_clean_start' -ATTR_CLEAN_BATTERY_END = 'battery_level_at_clean_end' -ATTR_CLEAN_SUSP_COUNT = 'clean_suspension_count' -ATTR_CLEAN_SUSP_TIME = 'clean_suspension_time' - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Neato sensor platform.""" - dev = [] - for robot in hass.data[NEATO_ROBOTS]: - for type_name in SENSOR_TYPES: - dev.append(NeatoConnectedSensor(hass, robot, type_name)) - _LOGGER.debug("Adding sensors %s", dev) - add_devices(dev) - - -class NeatoConnectedSensor(Entity): - """Neato Connected Sensor.""" - - def __init__(self, hass, robot, sensor_type): - """Initialize the Neato Connected sensor.""" - self.type = sensor_type - self.robot = robot - self.neato = hass.data[NEATO_LOGIN] - self._robot_name = self.robot.name + ' ' + SENSOR_TYPES[self.type][0] - self._status_state = None - try: - self._state = self.robot.state - except (requests.exceptions.ConnectionError, - requests.exceptions.HTTPError) as ex: - self._state = None - _LOGGER.warning("Neato connection error: %s", ex) - self._mapdata = hass.data[NEATO_MAP_DATA] - self.clean_time_start = None - self.clean_time_stop = None - self.clean_area = None - self.clean_battery_start = None - self.clean_battery_end = None - self.clean_suspension_charge_count = None - self.clean_suspension_time = None - self._battery_state = None - - def update(self): - """Update the properties of sensor.""" - _LOGGER.debug('Update of sensor') - self.neato.update_robots() - self._mapdata = self.hass.data[NEATO_MAP_DATA] - try: - self._state = self.robot.state - except (requests.exceptions.ConnectionError, - requests.exceptions.HTTPError) as ex: - self._state = None - self._status_state = 'Offline' - _LOGGER.warning("Neato connection error: %s", ex) - return - if not self._state: - return - _LOGGER.debug('self._state=%s', self._state) - if self.type == SENSOR_TYPE_STATUS: - if self._state['state'] == 1: - if self._state['details']['isCharging']: - self._status_state = 'Charging' - elif (self._state['details']['isDocked'] and - not self._state['details']['isCharging']): - self._status_state = 'Docked' - else: - self._status_state = 'Stopped' - elif self._state['state'] == 2: - if ALERTS.get(self._state['error']) is None: - self._status_state = ( - MODE.get(self._state['cleaning']['mode']) - + ' ' + ACTION.get(self._state['action'])) - else: - self._status_state = ALERTS.get(self._state['error']) - elif self._state['state'] == 3: - self._status_state = 'Paused' - elif self._state['state'] == 4: - self._status_state = ERRORS.get(self._state['error']) - if self.type == SENSOR_TYPE_BATTERY: - self._battery_state = self._state['details']['charge'] - if not self._mapdata.get(self.robot.serial, {}).get('maps', []): - return - self.clean_time_start = ( - (self._mapdata[self.robot.serial]['maps'][0]['start_at'] - .strip('Z')) - .replace('T', ' ')) - self.clean_time_stop = ( - (self._mapdata[self.robot.serial]['maps'][0]['end_at'].strip('Z')) - .replace('T', ' ')) - self.clean_area = ( - self._mapdata[self.robot.serial]['maps'][0]['cleaned_area']) - self.clean_suspension_charge_count = ( - self._mapdata[self.robot.serial]['maps'][0] - ['suspended_cleaning_charging_count']) - self.clean_suspension_time = ( - self._mapdata[self.robot.serial]['maps'][0] - ['time_in_suspended_cleaning']) - self.clean_battery_start = ( - self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_start']) - self.clean_battery_end = ( - self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_end']) - - @property - def unit_of_measurement(self): - """Return unit for the sensor.""" - if self.type == SENSOR_TYPE_BATTERY: - return '%' - - @property - def available(self): - """Return True if sensor data is available.""" - return self._state - - @property - def state(self): - """Return the sensor state.""" - if self.type == SENSOR_TYPE_STATUS: - return self._status_state - if self.type == SENSOR_TYPE_BATTERY: - return self._battery_state - - @property - def name(self): - """Return the name of the sensor.""" - return self._robot_name - - @property - def device_state_attributes(self): - """Return the device specific attributes.""" - data = {} - if self.type is SENSOR_TYPE_STATUS: - if self.clean_time_start: - data[ATTR_CLEAN_START] = self.clean_time_start - if self.clean_time_stop: - data[ATTR_CLEAN_STOP] = self.clean_time_stop - if self.clean_area: - data[ATTR_CLEAN_AREA] = self.clean_area - if self.clean_suspension_charge_count: - data[ATTR_CLEAN_SUSP_COUNT] = ( - self.clean_suspension_charge_count) - if self.clean_suspension_time: - data[ATTR_CLEAN_SUSP_TIME] = self.clean_suspension_time - if self.clean_battery_start: - data[ATTR_CLEAN_BATTERY_START] = self.clean_battery_start - if self.clean_battery_end: - data[ATTR_CLEAN_BATTERY_END] = self.clean_battery_end - return data diff --git a/homeassistant/components/switch/neato.py b/homeassistant/components/switch/neato.py index f29dc31eaf0..62bc5f99d01 100644 --- a/homeassistant/components/switch/neato.py +++ b/homeassistant/components/switch/neato.py @@ -14,11 +14,9 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['neato'] -SWITCH_TYPE_CLEAN = 'clean' -SWITCH_TYPE_SCHEDULE = 'scedule' +SWITCH_TYPE_SCHEDULE = 'schedule' SWITCH_TYPES = { - SWITCH_TYPE_CLEAN: ['Clean'], SWITCH_TYPE_SCHEDULE: ['Schedule'] } @@ -64,15 +62,6 @@ class NeatoConnectedSwitch(ToggleEntity): self._state = None return _LOGGER.debug('self._state=%s', self._state) - if self.type == SWITCH_TYPE_CLEAN: - if (self.robot.state['action'] == 1 or - self.robot.state['action'] == 2 or - self.robot.state['action'] == 3 and - self.robot.state['state'] == 2): - self._clean_state = STATE_ON - else: - self._clean_state = STATE_OFF - _LOGGER.debug("Clean state: %s", self._clean_state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) if self.robot.schedule_enabled: @@ -94,26 +83,17 @@ class NeatoConnectedSwitch(ToggleEntity): @property def is_on(self): """Return true if switch is on.""" - if self.type == SWITCH_TYPE_CLEAN: - if self._clean_state == STATE_ON: - return True - return False - elif self.type == SWITCH_TYPE_SCHEDULE: + if self.type == SWITCH_TYPE_SCHEDULE: if self._schedule_state == STATE_ON: return True return False def turn_on(self, **kwargs): """Turn the switch on.""" - if self.type == SWITCH_TYPE_CLEAN: - self.robot.start_cleaning() - elif self.type == SWITCH_TYPE_SCHEDULE: + if self.type == SWITCH_TYPE_SCHEDULE: self.robot.enable_schedule() def turn_off(self, **kwargs): """Turn the switch off.""" - if self.type == SWITCH_TYPE_CLEAN: - self.robot.pause_cleaning() - self.robot.send_to_base() - elif self.type == SWITCH_TYPE_SCHEDULE: + if self.type == SWITCH_TYPE_SCHEDULE: self.robot.disable_schedule() diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py new file mode 100644 index 00000000000..e1c4a5952af --- /dev/null +++ b/homeassistant/components/vacuum/neato.py @@ -0,0 +1,214 @@ +""" +Support for Neato Connected Vaccums. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/vacuum.neato/ +""" +import logging + +import requests + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.vacuum import ( + VacuumDevice, SUPPORT_BATTERY, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, + SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_MAP, ATTR_STATUS, ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON) +from homeassistant.components.neato import ( + NEATO_ROBOTS, NEATO_LOGIN, NEATO_MAP_DATA, ACTION, ERRORS, MODE, ALERTS) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['neato'] + +SUPPORT_NEATO = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \ + SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ + SUPPORT_STATUS | SUPPORT_MAP + +ICON = "mdi:roomba" + +ATTR_CLEAN_START = 'clean_start' +ATTR_CLEAN_STOP = 'clean_stop' +ATTR_CLEAN_AREA = 'clean_area' +ATTR_CLEAN_BATTERY_START = 'battery_level_at_clean_start' +ATTR_CLEAN_BATTERY_END = 'battery_level_at_clean_end' +ATTR_CLEAN_SUSP_COUNT = 'clean_suspension_count' +ATTR_CLEAN_SUSP_TIME = 'clean_suspension_time' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Neato vacuum.""" + dev = [] + for robot in hass.data[NEATO_ROBOTS]: + dev.append(NeatoConnectedVacuum(hass, robot)) + _LOGGER.debug("Adding vacuums %s", dev) + add_devices(dev, True) + + +class NeatoConnectedVacuum(VacuumDevice): + """Neato Connected Vacuums.""" + + def __init__(self, hass, robot): + """Initialize the Neato Connected Vacuums.""" + self.robot = robot + self.neato = hass.data[NEATO_LOGIN] + self._name = '{}'.format(self.robot.name) + self._status_state = None + self._clean_state = None + self._state = None + self._mapdata = hass.data[NEATO_MAP_DATA] + self.clean_time_start = None + self.clean_time_stop = None + self.clean_area = None + self.clean_battery_start = None + self.clean_battery_end = None + self.clean_suspension_charge_count = None + self.clean_suspension_time = None + + def update(self): + """Update the states of Neato Vacuums.""" + _LOGGER.debug("Running Vacuums update") + self.neato.update_robots() + try: + self._state = self.robot.state + except (requests.exceptions.ConnectionError, + requests.exceptions.HTTPError) as ex: + _LOGGER.warning("Neato connection error: %s", ex) + self._state = None + return + _LOGGER.debug('self._state=%s', self._state) + if self._state['state'] == 1: + if self._state['details']['isCharging']: + self._status_state = 'Charging' + elif (self._state['details']['isDocked'] and + not self._state['details']['isCharging']): + self._status_state = 'Docked' + else: + self._status_state = 'Stopped' + elif self._state['state'] == 2: + if ALERTS.get(self._state['error']) is None: + self._status_state = ( + MODE.get(self._state['cleaning']['mode']) + + ' ' + ACTION.get(self._state['action'])) + else: + self._status_state = ALERTS.get(self._state['error']) + elif self._state['state'] == 3: + self._status_state = 'Paused' + elif self._state['state'] == 4: + self._status_state = ERRORS.get(self._state['error']) + + if (self.robot.state['action'] == 1 or + self.robot.state['action'] == 2 or + self.robot.state['action'] == 3 and + self.robot.state['state'] == 2): + self._clean_state = STATE_ON + else: + self._clean_state = STATE_OFF + + if not self._mapdata.get(self.robot.serial, {}).get('maps', []): + return + self.clean_time_start = ( + (self._mapdata[self.robot.serial]['maps'][0]['start_at'] + .strip('Z')) + .replace('T', ' ')) + self.clean_time_stop = ( + (self._mapdata[self.robot.serial]['maps'][0]['end_at'].strip('Z')) + .replace('T', ' ')) + self.clean_area = ( + self._mapdata[self.robot.serial]['maps'][0]['cleaned_area']) + self.clean_suspension_charge_count = ( + self._mapdata[self.robot.serial]['maps'][0] + ['suspended_cleaning_charging_count']) + self.clean_suspension_time = ( + self._mapdata[self.robot.serial]['maps'][0] + ['time_in_suspended_cleaning']) + self.clean_battery_start = ( + self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_start']) + self.clean_battery_end = ( + self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_end']) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def icon(self): + """Return the icon to use for device.""" + return ICON + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_NEATO + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + return self._state['details']['charge'] + + @property + def status(self): + """Return the status of the vacuum cleaner.""" + return self._status_state + + @property + def state_attributes(self): + """Return the state attributes of the vacuum cleaner.""" + data = {} + + if self.status is not None: + data[ATTR_STATUS] = self.status + + if self.battery_level is not None: + data[ATTR_BATTERY_LEVEL] = self.battery_level + data[ATTR_BATTERY_ICON] = self.battery_icon + + if self.clean_time_start is not None: + data[ATTR_CLEAN_START] = self.clean_time_start + if self.clean_time_stop is not None: + data[ATTR_CLEAN_STOP] = self.clean_time_stop + if self.clean_area is not None: + data[ATTR_CLEAN_AREA] = self.clean_area + if self.clean_suspension_charge_count is not None: + data[ATTR_CLEAN_SUSP_COUNT] = ( + self.clean_suspension_charge_count) + if self.clean_suspension_time is not None: + data[ATTR_CLEAN_SUSP_TIME] = self.clean_suspension_time + if self.clean_battery_start is not None: + data[ATTR_CLEAN_BATTERY_START] = self.clean_battery_start + if self.clean_battery_end is not None: + data[ATTR_CLEAN_BATTERY_END] = self.clean_battery_end + + return data + + def turn_on(self, **kwargs): + """Turn the vacuum on and start cleaning.""" + self.robot.start_cleaning() + + @property + def is_on(self): + """Return true if switch is on.""" + return self._clean_state == STATE_ON + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self.robot.pause_cleaning() + self.robot.send_to_base() + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + self.robot.send_to_base() + + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + self.robot.stop_cleaning() + + def start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + if self._state['state'] == 1: + self.robot.start_cleaning() + elif self._state['state'] == 2 and\ + ALERTS.get(self._state['error']) is None: + self.robot.pause_cleaning() + if self._state['state'] == 3: + self.robot.resume_cleaning() From a43f99a71cfdc1d7b08d687c1a9963b33a315047 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 3 Nov 2017 15:38:15 +0100 Subject: [PATCH 012/137] Allow an empty MAC address at the Xiaomi Aqara Gateway configuration. (#10307) --- homeassistant/components/xiaomi_aqara.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 26950322857..f875edef310 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -49,7 +49,7 @@ SERVICE_SCHEMA_REMOVE_DEVICE = vol.Schema({ GATEWAY_CONFIG = vol.Schema({ - vol.Optional(CONF_MAC): GW_MAC, + vol.Optional(CONF_MAC, default=None): vol.Any(GW_MAC, None), vol.Optional(CONF_KEY, default=None): vol.All(cv.string, vol.Length(min=16, max=16)), vol.Optional(CONF_HOST): cv.string, From 81324806d53adf004fa1db6dff8c70341ec86180 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 3 Nov 2017 15:43:30 +0100 Subject: [PATCH 013/137] Move constants to setup.py (#10312) * Remove unused import * Move setup relevant consts to 'setup.py' * remove blank line * Set source --- docs/source/conf.py | 16 ++++----- homeassistant/components/dialogflow.py | 6 ++-- homeassistant/components/no_ip.py | 7 ++-- homeassistant/const.py | 37 +++------------------ homeassistant/setup.py | 5 ++- setup.py | 45 ++++++++++++++++++++++---- 6 files changed, 59 insertions(+), 57 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 8ca22e1a126..595c15717eb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,15 +19,13 @@ # import sys import os -from os.path import relpath import inspect -from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, - PROJECT_LONG_DESCRIPTION, - PROJECT_COPYRIGHT, PROJECT_AUTHOR, - PROJECT_GITHUB_USERNAME, - PROJECT_GITHUB_REPOSITORY, - GITHUB_PATH, GITHUB_URL) +from homeassistant.const import __version__, __short_version__ +from setup import ( + PROJECT_NAME, PROJECT_LONG_DESCRIPTION, PROJECT_COPYRIGHT, PROJECT_AUTHOR, + PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY, GITHUB_PATH, + GITHUB_URL) sys.path.insert(0, os.path.abspath('_ext')) sys.path.insert(0, os.path.abspath('../homeassistant')) @@ -87,9 +85,7 @@ edit_on_github_src_path = 'docs/source/' def linkcode_resolve(domain, info): - """ - Determine the URL corresponding to Python object - """ + """Determine the URL corresponding to Python object.""" if domain != 'py': return None modname = info['module'] diff --git a/homeassistant/components/dialogflow.py b/homeassistant/components/dialogflow.py index 3f2cae112f5..726b8d99e01 100644 --- a/homeassistant/components/dialogflow.py +++ b/homeassistant/components/dialogflow.py @@ -9,7 +9,7 @@ import logging import voluptuous as vol -from homeassistant.const import PROJECT_NAME, HTTP_BAD_REQUEST +from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import intent, template from homeassistant.components.http import HomeAssistantView @@ -26,6 +26,8 @@ DOMAIN = 'dialogflow' INTENTS_API_ENDPOINT = '/api/dialogflow' +SOURCE = "Home Assistant Dialogflow" + CONFIG_SCHEMA = vol.Schema({ DOMAIN: {} }, extra=vol.ALLOW_EXTRA) @@ -128,5 +130,5 @@ class DialogflowResponse(object): return { 'speech': self.speech, 'displayText': self.speech, - 'source': PROJECT_NAME, + 'source': SOURCE, } diff --git a/homeassistant/components/no_ip.py b/homeassistant/components/no_ip.py index d92cd752aef..48bd681ac62 100644 --- a/homeassistant/components/no_ip.py +++ b/homeassistant/components/no_ip.py @@ -16,13 +16,16 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, HTTP_HEADER_AUTH, - HTTP_HEADER_USER_AGENT, PROJECT_EMAIL) + HTTP_HEADER_USER_AGENT) from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE _LOGGER = logging.getLogger(__name__) DOMAIN = 'no_ip' +# We should set a dedicated address for the user agent. +EMAIL = 'hello@home-assistant.io' + INTERVAL = timedelta(minutes=5) DEFAULT_TIMEOUT = 10 @@ -38,7 +41,7 @@ NO_IP_ERRORS = { } UPDATE_URL = 'https://dynupdate.noip.com/nic/update' -USER_AGENT = "{} {}".format(SERVER_SOFTWARE, PROJECT_EMAIL) +USER_AGENT = "{} {}".format(SERVER_SOFTWARE, EMAIL) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/const.py b/homeassistant/const.py index aef543e3666..f9a1ed13e22 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -9,37 +9,7 @@ REQUIRED_PYTHON_VER = (3, 4, 2) REQUIRED_PYTHON_VER_WIN = (3, 5, 2) CONSTRAINT_FILE = 'package_constraints.txt' -PROJECT_NAME = 'Home Assistant' -PROJECT_PACKAGE_NAME = 'homeassistant' -PROJECT_LICENSE = 'Apache License 2.0' -PROJECT_AUTHOR = 'The Home Assistant Authors' -PROJECT_COPYRIGHT = ' 2013, {}'.format(PROJECT_AUTHOR) -PROJECT_URL = 'https://home-assistant.io/' -PROJECT_EMAIL = 'hello@home-assistant.io' -PROJECT_DESCRIPTION = ('Open-source home automation platform ' - 'running on Python 3.') -PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' - 'home automation platform running on Python 3. ' - 'Track and control all devices at home and ' - 'automate control. ' - 'Installation in less than a minute.') -PROJECT_CLASSIFIERS = [ - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.4', - 'Topic :: Home Automation' -] - -PROJECT_GITHUB_USERNAME = 'home-assistant' -PROJECT_GITHUB_REPOSITORY = 'home-assistant' - -PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) -GITHUB_PATH = '{}/{}'.format(PROJECT_GITHUB_USERNAME, - PROJECT_GITHUB_REPOSITORY) -GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) - +# Format for platforms PLATFORM_FORMAT = '{}.{}' # Can be used to specify a catch all when registering state or event listeners. @@ -48,8 +18,7 @@ MATCH_ALL = '*' # If no name is specified DEVICE_DEFAULT_NAME = 'Unnamed Device' -WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] - +# Sun events SUN_EVENT_SUNSET = 'sunset' SUN_EVENT_SUNRISE = 'sunrise' @@ -463,3 +432,5 @@ VOLUME = 'volume' # type: str TEMPERATURE = 'temperature' # type: str SPEED_MS = 'speed_ms' # type: str ILLUMINANCE = 'illuminance' # type: str + +WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] diff --git a/homeassistant/setup.py b/homeassistant/setup.py index a7083d010e6..05a8ee1e2f1 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -1,6 +1,5 @@ """All methods needed to bootstrap a Home Assistant instance.""" import asyncio -import logging import logging.handlers import os from timeit import default_timer as timer @@ -9,13 +8,13 @@ from types import ModuleType from typing import Optional, Dict import homeassistant.config as conf_util -from homeassistant.config import async_notify_setup_error import homeassistant.core as core import homeassistant.loader as loader import homeassistant.util.package as pkg_util -from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.config import async_notify_setup_error from homeassistant.const import ( EVENT_COMPONENT_LOADED, PLATFORM_FORMAT, CONSTRAINT_FILE) +from homeassistant.util.async import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index cd7043650ad..2bb78877f6d 100755 --- a/setup.py +++ b/setup.py @@ -2,15 +2,46 @@ """Home Assistant setup script.""" import os from setuptools import setup, find_packages -from homeassistant.const import (__version__, PROJECT_PACKAGE_NAME, - PROJECT_LICENSE, PROJECT_URL, - PROJECT_EMAIL, PROJECT_DESCRIPTION, - PROJECT_CLASSIFIERS, GITHUB_URL, - PROJECT_AUTHOR) + +from homeassistant.const import __version__ + +PROJECT_NAME = 'Home Assistant' +PROJECT_PACKAGE_NAME = 'homeassistant' +PROJECT_LICENSE = 'Apache License 2.0' +PROJECT_AUTHOR = 'The Home Assistant Authors' +PROJECT_COPYRIGHT = ' 2013-2017, {}'.format(PROJECT_AUTHOR) +PROJECT_URL = 'https://home-assistant.io/' +PROJECT_EMAIL = 'hello@home-assistant.io' +PROJECT_DESCRIPTION = ('Open-source home automation platform ' + 'running on Python 3.') +PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' + 'home automation platform running on Python 3. ' + 'Track and control all devices at home and ' + 'automate control. ' + 'Installation in less than a minute.') +PROJECT_CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Home Automation' +] + +PROJECT_GITHUB_USERNAME = 'home-assistant' +PROJECT_GITHUB_REPOSITORY = 'home-assistant' + +PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) +GITHUB_PATH = '{}/{}'.format( + PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) + HERE = os.path.abspath(os.path.dirname(__file__)) -DOWNLOAD_URL = ('{}/archive/' - '{}.zip'.format(GITHUB_URL, __version__)) +DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, __version__) PACKAGES = find_packages(exclude=['tests', 'tests.*']) From 1ffccfc91ccf559d2f573a18a864474e610e5e29 Mon Sep 17 00:00:00 2001 From: PeteBa Date: Fri, 3 Nov 2017 15:28:16 +0000 Subject: [PATCH 014/137] Maintain recorder purge schedule (#10279) * Maintain automated purge schedule * Updates from review feedback --- homeassistant/components/recorder/__init__.py | 67 ++++++++++++------- tests/components/recorder/test_init.py | 3 +- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index a285486437e..df19f0125ef 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -14,6 +14,7 @@ from os import path import queue import threading import time +from collections import namedtuple from datetime import datetime, timedelta from typing import Optional, Dict @@ -27,7 +28,6 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import generate_filter -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from homeassistant import config as conf_util @@ -121,7 +121,7 @@ def run_information(hass, point_in_time: Optional[datetime]=None): def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" conf = config.get(DOMAIN, {}) - purge_days = conf.get(CONF_PURGE_KEEP_DAYS) + keep_days = conf.get(CONF_PURGE_KEEP_DAYS) purge_interval = conf.get(CONF_PURGE_INTERVAL) db_url = conf.get(CONF_DB_URL, None) @@ -132,28 +132,20 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: include = conf.get(CONF_INCLUDE, {}) exclude = conf.get(CONF_EXCLUDE, {}) instance = hass.data[DATA_INSTANCE] = Recorder( - hass, uri=db_url, include=include, exclude=exclude) + hass=hass, keep_days=keep_days, purge_interval=purge_interval, + uri=db_url, include=include, exclude=exclude) instance.async_initialize() instance.start() - @asyncio.coroutine - def async_handle_purge_interval(now): - """Handle purge interval.""" - instance.do_purge(purge_days) - @asyncio.coroutine def async_handle_purge_service(service): """Handle calls to the purge service.""" - instance.do_purge(service.data[ATTR_KEEP_DAYS]) + instance.do_adhoc_purge(service.data[ATTR_KEEP_DAYS]) descriptions = yield from hass.async_add_job( conf_util.load_yaml_config_file, path.join( path.dirname(__file__), 'services.yaml')) - if purge_interval and purge_days: - async_track_time_interval(hass, async_handle_purge_interval, - timedelta(days=purge_interval)) - hass.services.async_register(DOMAIN, SERVICE_PURGE, async_handle_purge_service, descriptions.get(SERVICE_PURGE), @@ -162,16 +154,21 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return (yield from instance.async_db_ready) +PurgeTask = namedtuple('PurgeTask', ['keep_days']) + + class Recorder(threading.Thread): """A threaded recorder class.""" - def __init__(self, hass: HomeAssistant, uri: str, + def __init__(self, hass: HomeAssistant, keep_days: int, + purge_interval: int, uri: str, include: Dict, exclude: Dict) -> None: """Initialize the recorder.""" threading.Thread.__init__(self, name='Recorder') self.hass = hass - self.purge_days = None + self.keep_days = keep_days + self.purge_interval = purge_interval self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri @@ -186,18 +183,16 @@ class Recorder(threading.Thread): self.exclude_t = exclude.get(CONF_EVENT_TYPES, []) self.get_session = None - self.purge_task = object() @callback def async_initialize(self): """Initialize the recorder.""" self.hass.bus.async_listen(MATCH_ALL, self.event_listener) - def do_purge(self, purge_days=None): - """Event listener for purging data.""" - if purge_days is not None: - self.purge_days = purge_days - self.queue.put(self.purge_task) + def do_adhoc_purge(self, keep_days): + """Trigger an adhoc purge retaining keep_days worth of data.""" + if keep_days is not None: + self.queue.put(PurgeTask(keep_days)) def run(self): """Start processing events to save.""" @@ -264,6 +259,31 @@ class Recorder(threading.Thread): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, notify_hass_started) + if self.keep_days and self.purge_interval: + async_track_point_in_time = \ + self.hass.helpers.event.async_track_point_in_time + + @callback + def async_purge(now): + """Trigger the purge and schedule the next run.""" + self.queue.put(PurgeTask(self.keep_days)) + async_track_point_in_time(async_purge, now + timedelta( + days=self.purge_interval)) + + earliest = dt_util.utcnow() + timedelta(minutes=30) + run = latest = dt_util.utcnow() + \ + timedelta(days=self.purge_interval) + with session_scope(session=self.get_session()) as session: + event = session.query(Events).first() + if event is not None: + session.expunge(event) + run = dt_util.UTC.localize(event.time_fired) + \ + timedelta(days=self.keep_days+self.purge_interval) + run = min(latest, max(run, earliest)) + + _LOGGER.debug("Scheduling purge run for %s", run) + async_track_point_in_time(async_purge, run) + self.hass.add_job(register) result = hass_started.result() @@ -279,8 +299,9 @@ class Recorder(threading.Thread): self._close_connection() self.queue.task_done() return - elif event is self.purge_task: - purge.purge_old_data(self, self.purge_days) + elif isinstance(event, PurgeTask): + purge.purge_old_data(self, event.keep_days) + self.queue.task_done() continue elif event.event_type == EVENT_TIME_CHANGED: self.queue.task_done() diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index ed04e96a43c..58b8dc1f839 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -195,7 +195,8 @@ def test_recorder_setup_failure(): with patch.object(Recorder, '_setup_connection') as setup, \ patch('homeassistant.components.recorder.time.sleep'): setup.side_effect = ImportError("driver not found") - rec = Recorder(hass, uri='sqlite://', include={}, exclude={}) + rec = Recorder(hass, keep_days=7, purge_interval=2, + uri='sqlite://', include={}, exclude={}) rec.start() rec.join() From 31b89f602affb4f8d9bcd672a0868900c39be982 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Fri, 3 Nov 2017 11:58:03 -0400 Subject: [PATCH 015/137] Strip white space from configurator input (#10317) --- homeassistant/components/wink/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 32e5938f6c7..426893ec306 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -160,7 +160,6 @@ def _request_app_setup(hass, config): hass.data[DOMAIN]['configurator'] = True configurator = hass.components.configurator - # pylint: disable=unused-argument def wink_configuration_callback(callback_data): """Handle configuration updates.""" _config_path = hass.config.path(WINK_CONFIG_FILE) @@ -168,8 +167,8 @@ def _request_app_setup(hass, config): setup(hass, config) return - client_id = callback_data.get('client_id') - client_secret = callback_data.get('client_secret') + client_id = callback_data.get('client_id').strip() + client_secret = callback_data.get('client_secret').strip() if None not in (client_id, client_secret): save_json(_config_path, {ATTR_CLIENT_ID: client_id, From 0877ea07b348bdd4f9eac7cd4cd381cc8b4121bc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Nov 2017 10:12:45 -0700 Subject: [PATCH 016/137] Fix formatting invalid config text (#10319) --- homeassistant/config.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 89289378c76..c4c96804fca 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -677,9 +677,18 @@ def async_notify_setup_error(hass, component, link=False): errors = hass.data[DATA_PERSISTENT_ERRORS] = {} errors[component] = errors.get(component) or link - _lst = [HA_COMPONENT_URL.format(name.replace('_', '-'), name) - if link else name for name, link in errors.items()] - message = ('The following components and platforms could not be set up:\n' - '* ' + '\n* '.join(list(_lst)) + '\nPlease check your config') + + message = 'The following components and platforms could not be set up:\n\n' + + for name, link in errors.items(): + if link: + part = HA_COMPONENT_URL.format(name.replace('_', '-'), name) + else: + part = name + + message += ' - {}\n'.format(part) + + message += '\nPlease check your config.' + persistent_notification.async_create( hass, message, 'Invalid config', 'invalid_config') From 06d3d8b827f59d6f0ea1d4be1b3ec038b251ad4a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Nov 2017 20:31:48 +0100 Subject: [PATCH 017/137] TellStick / Remove async flavor / add hassio (#10315) * Remove unused async flavor * Add tellcore-net support * Update tellstick.py * Update requirements_all.txt * fix lint --- homeassistant/components/tellstick.py | 34 +++++++++++++++++++++------ requirements_all.txt | 3 +++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index 6ae96b88da7..85407ff4c7a 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -10,16 +10,18 @@ import threading import voluptuous as vol from homeassistant.helpers import discovery -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['tellcore-py==1.1.2'] +REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.1'] _LOGGER = logging.getLogger(__name__) ATTR_DISCOVER_CONFIG = 'config' ATTR_DISCOVER_DEVICES = 'devices' -ATTR_SIGNAL_REPETITIONS = 'signal_repetitions' +CONF_SIGNAL_REPETITIONS = 'signal_repetitions' DEFAULT_SIGNAL_REPETITIONS = 1 DOMAIN = 'tellstick' @@ -34,7 +36,9 @@ TELLCORE_REGISTRY = None CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(ATTR_SIGNAL_REPETITIONS, + vol.Inclusive(CONF_HOST, 'tellcore-net'): cv.string, + vol.Inclusive(CONF_PORT, 'tellcore-net'): cv.port, + vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): vol.Coerce(int), }), }, extra=vol.ALLOW_EXTRA) @@ -48,7 +52,7 @@ def _discover(hass, config, component_name, found_tellcore_devices): _LOGGER.info("Discovered %d new %s devices", len(found_tellcore_devices), component_name) - signal_repetitions = config[DOMAIN].get(ATTR_SIGNAL_REPETITIONS) + signal_repetitions = config[DOMAIN].get(CONF_SIGNAL_REPETITIONS) discovery.load_platform(hass, component_name, DOMAIN, { ATTR_DISCOVER_DEVICES: found_tellcore_devices, @@ -58,12 +62,28 @@ def _discover(hass, config, component_name, found_tellcore_devices): def setup(hass, config): """Set up the Tellstick component.""" from tellcore.constants import TELLSTICK_DIM - from tellcore.telldus import AsyncioCallbackDispatcher + from tellcore.telldus import QueuedCallbackDispatcher from tellcore.telldus import TelldusCore + from tellcorenet import TellCoreClient + + conf = config.get(DOMAIN, {}) + net_host = conf.get(CONF_HOST) + net_port = conf.get(CONF_PORT) + + # Initialize remote tellcore client + if net_host and net_port: + net_client = TellCoreClient(net_host, net_port) + net_client.start() + + def stop_tellcore_net(event): + """Event handler to stop the client.""" + net_client.stop() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_tellcore_net) try: tellcore_lib = TelldusCore( - callback_dispatcher=AsyncioCallbackDispatcher(hass.loop)) + callback_dispatcher=QueuedCallbackDispatcher()) except OSError: _LOGGER.exception("Could not initialize Tellstick") return False diff --git a/requirements_all.txt b/requirements_all.txt index ae5ebab2133..f32d84fb2b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1045,6 +1045,9 @@ tank_utility==1.4.0 # homeassistant.components.binary_sensor.tapsaff tapsaff==0.1.3 +# homeassistant.components.tellstick +tellcore-net==0.1 + # homeassistant.components.tellstick # homeassistant.components.sensor.tellstick tellcore-py==1.1.2 From a4dec0b6d22e52fc6c8d3fed2eced25d78c5eced Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Nov 2017 20:55:00 +0100 Subject: [PATCH 018/137] Fix recorder purge (#10318) * Fix recorder purge * Fix lint * fix utc convert --- homeassistant/components/recorder/__init__.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index df19f0125ef..e9b08941b83 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -260,15 +260,12 @@ class Recorder(threading.Thread): notify_hass_started) if self.keep_days and self.purge_interval: - async_track_point_in_time = \ - self.hass.helpers.event.async_track_point_in_time - @callback def async_purge(now): """Trigger the purge and schedule the next run.""" self.queue.put(PurgeTask(self.keep_days)) - async_track_point_in_time(async_purge, now + timedelta( - days=self.purge_interval)) + self.hass.helpers.event.async_track_point_in_time( + async_purge, now + timedelta(days=self.purge_interval)) earliest = dt_util.utcnow() + timedelta(minutes=30) run = latest = dt_util.utcnow() + \ @@ -277,12 +274,11 @@ class Recorder(threading.Thread): event = session.query(Events).first() if event is not None: session.expunge(event) - run = dt_util.UTC.localize(event.time_fired) + \ + run = dt_util.as_utc(event.time_fired) + \ timedelta(days=self.keep_days+self.purge_interval) run = min(latest, max(run, earliest)) - - _LOGGER.debug("Scheduling purge run for %s", run) - async_track_point_in_time(async_purge, run) + self.hass.helpers.event.async_track_point_in_time( + async_purge, run) self.hass.add_job(register) result = hass_started.result() From 96657841c8ee5951199109cb656c0558e9adf8b5 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Fri, 3 Nov 2017 16:02:38 -0400 Subject: [PATCH 019/137] Add option to overwrite file to the downloader component (#10298) * Add option to overwrite file to the downloader component * Cleanup * Address Paulus's comments --- homeassistant/components/downloader.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 07f432a5218..5c9ced1fd89 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -20,6 +20,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_FILENAME = 'filename' ATTR_SUBDIR = 'subdir' ATTR_URL = 'url' +ATTR_OVERWRITE = 'overwrite' CONF_DOWNLOAD_DIR = 'download_dir' @@ -31,6 +32,7 @@ SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ vol.Required(ATTR_URL): cv.url, vol.Optional(ATTR_SUBDIR): cv.string, vol.Optional(ATTR_FILENAME): cv.string, + vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, }) CONFIG_SCHEMA = vol.Schema({ @@ -66,6 +68,8 @@ def setup(hass, config): filename = service.data.get(ATTR_FILENAME) + overwrite = service.data.get(ATTR_OVERWRITE) + if subdir: subdir = sanitize_filename(subdir) @@ -109,12 +113,13 @@ def setup(hass, config): # If file exist append a number. # We test filename, filename_2.. - tries = 1 - final_path = path + ext - while os.path.isfile(final_path): - tries += 1 + if not overwrite: + tries = 1 + final_path = path + ext + while os.path.isfile(final_path): + tries += 1 - final_path = "{}_{}.{}".format(path, tries, ext) + final_path = "{}_{}.{}".format(path, tries, ext) _LOGGER.info("%s -> %s", url, final_path) From acfee385fb9237d82ef6e2c9b76e0455ea36c78e Mon Sep 17 00:00:00 2001 From: "Craig J. Ward" Date: Fri, 3 Nov 2017 22:46:40 -0500 Subject: [PATCH 020/137] Tc update (#10322) * use updated client * update requirements --- homeassistant/components/alarm_control_panel/totalconnect.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 05dc8aeef20..7abdf5efcab 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) -REQUIREMENTS = ['total_connect_client==0.11'] +REQUIREMENTS = ['total_connect_client==0.12'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f32d84fb2b2..bf4ee2b673e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1074,7 +1074,7 @@ todoist-python==7.0.17 toonlib==1.0.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.11 +total_connect_client==0.12 # homeassistant.components.sensor.transmission # homeassistant.components.switch.transmission From 0f7a4b1d6fa7908588945a1f04c448ee9636dbd3 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 4 Nov 2017 05:10:08 +0100 Subject: [PATCH 021/137] Move timer into correct folder (#10324) * Move timer into correct folder * Rename tests/components/test_timer.py to tests/components/timer/test_timer.py * create init for test component * Fix services.yaml loading --- homeassistant/components/{timer.py => timer/__init__.py} | 4 ++-- tests/components/timer/__init__.py | 1 + tests/components/{test_timer.py => timer/test_init.py} | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename homeassistant/components/{timer.py => timer/__init__.py} (98%) create mode 100644 tests/components/timer/__init__.py rename tests/components/{test_timer.py => timer/test_init.py} (100%) diff --git a/homeassistant/components/timer.py b/homeassistant/components/timer/__init__.py similarity index 98% rename from homeassistant/components/timer.py rename to homeassistant/components/timer/__init__.py index 4d21cca40bb..b2f5db88b5f 100644 --- a/homeassistant/components/timer.py +++ b/homeassistant/components/timer/__init__.py @@ -166,8 +166,8 @@ def async_setup(hass, config): 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__), os.path.join(DOMAIN, 'services.yaml')) + load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml') ) hass.services.async_register( diff --git a/tests/components/timer/__init__.py b/tests/components/timer/__init__.py new file mode 100644 index 00000000000..160fc633701 --- /dev/null +++ b/tests/components/timer/__init__.py @@ -0,0 +1 @@ +"""Test env for timer component.""" diff --git a/tests/components/test_timer.py b/tests/components/timer/test_init.py similarity index 100% rename from tests/components/test_timer.py rename to tests/components/timer/test_init.py From e64803e701284358a0915e9e626f63d420e5f208 Mon Sep 17 00:00:00 2001 From: marconfus Date: Sat, 4 Nov 2017 12:58:02 +0100 Subject: [PATCH 022/137] Fix for API change of new enocean package (#10328) * Fix API change of new enocean package * Fix lint issue --- homeassistant/components/enocean.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 3c3eefe54cc..879f6a61899 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -72,6 +72,7 @@ class EnOceanDongle: """ from enocean.protocol.packet import RadioPacket if isinstance(temp, RadioPacket): + _LOGGER.debug("Received radio packet: %s", temp) rxtype = None value = None if temp.data[6] == 0x30: @@ -94,20 +95,20 @@ class EnOceanDongle: value = temp.data[2] for device in self.__devices: if rxtype == "wallswitch" and device.stype == "listener": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value, temp.data[1]) if rxtype == "power" and device.stype == "powersensor": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "power" and device.stype == "switch": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): if value > 10: device.value_changed(1) if rxtype == "switch_status" and device.stype == "switch": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "dimmerstatus" and device.stype == "dimmer": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) From de9d19d6f44f3b4cd4a2425315e5d3b2d23f205f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 4 Nov 2017 20:04:05 +0100 Subject: [PATCH 023/137] Use constants for HTTP headers (#10313) * Use constants for HTTP headers * Fix ordering * Move 'no-cache' to platform --- .../components/binary_sensor/aurora.py | 39 ++--- homeassistant/components/bloomsky.py | 7 +- .../components/device_tracker/swisscom.py | 5 +- .../components/device_tracker/tplink.py | 78 +++++----- .../components/device_tracker/upc_connect.py | 31 ++-- .../components/google_assistant/http.py | 27 ++-- homeassistant/components/http/__init__.py | 34 +++-- .../components/media_player/__init__.py | 38 +++-- .../components/media_player/bluesound.py | 136 +++++++----------- homeassistant/components/no_ip.py | 14 +- homeassistant/components/notify/clicksend.py | 12 +- .../components/notify/clicksend_tts.py | 12 +- homeassistant/components/notify/facebook.py | 8 +- homeassistant/components/notify/html5.py | 63 ++++---- homeassistant/components/notify/instapush.py | 10 +- homeassistant/components/notify/sendgrid.py | 5 +- homeassistant/components/notify/telstra.py | 9 +- homeassistant/components/octoprint.py | 7 +- homeassistant/components/scene/lifx_cloud.py | 12 +- .../components/scene/lutron_caseta.py | 2 +- .../components/sensor/haveibeenpwned.py | 43 +++--- homeassistant/components/sensor/nzbget.py | 13 +- homeassistant/components/sensor/pyload.py | 12 +- .../components/sensor/thethingsnetwork.py | 7 +- homeassistant/components/sensor/zamg.py | 24 ++-- homeassistant/components/splunk.py | 5 +- .../components/telegram_bot/polling.py | 19 +-- homeassistant/components/tts/google.py | 13 +- homeassistant/const.py | 17 --- homeassistant/remote.py | 40 +++--- tests/components/emulated_hue/test_hue_api.py | 25 ++-- tests/components/emulated_hue/test_upnp.py | 3 +- .../google_assistant/test_google_assistant.py | 26 ++-- tests/components/http/test_init.py | 38 ++--- tests/components/notify/test_html5.py | 15 +- tests/components/test_dialogflow.py | 3 +- 36 files changed, 408 insertions(+), 444 deletions(-) diff --git a/homeassistant/components/binary_sensor/aurora.py b/homeassistant/components/binary_sensor/aurora.py index 2530fecb7c1..772792f5785 100644 --- a/homeassistant/components/binary_sensor/aurora.py +++ b/homeassistant/components/binary_sensor/aurora.py @@ -7,25 +7,32 @@ https://home-assistant.io/components/binary_sensor.aurora/ from datetime import timedelta import logging +from aiohttp.hdrs import USER_AGENT import requests import voluptuous as vol -from homeassistant.components.binary_sensor \ - import (BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME) +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -CONF_THRESHOLD = "forecast_threshold" - _LOGGER = logging.getLogger(__name__) +CONF_ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric" \ + "Administration" +CONF_THRESHOLD = 'forecast_threshold' + +DEFAULT_DEVICE_CLASS = 'visible' DEFAULT_NAME = 'Aurora Visibility' -DEFAULT_DEVICE_CLASS = "visible" DEFAULT_THRESHOLD = 75 +HA_USER_AGENT = "Home Assistant Aurora Tracker v.0.1.0" + MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) +URL = "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_THRESHOLD, default=DEFAULT_THRESHOLD): cv.positive_int, @@ -43,10 +50,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: aurora_data = AuroraData( - hass.config.latitude, - hass.config.longitude, - threshold - ) + hass.config.latitude, hass.config.longitude, threshold) aurora_data.update() except requests.exceptions.HTTPError as error: _LOGGER.error( @@ -85,9 +89,9 @@ class AuroraSensor(BinarySensorDevice): attrs = {} if self.aurora_data: - attrs["visibility_level"] = self.aurora_data.visibility_level - attrs["message"] = self.aurora_data.is_visible_text - + attrs['visibility_level'] = self.aurora_data.visibility_level + attrs['message'] = self.aurora_data.is_visible_text + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION return attrs def update(self): @@ -104,10 +108,7 @@ class AuroraData(object): self.longitude = longitude self.number_of_latitude_intervals = 513 self.number_of_longitude_intervals = 1024 - self.api_url = \ - "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt" - self.headers = {"User-Agent": "Home Assistant Aurora Tracker v.0.1.0"} - + self.headers = {USER_AGENT: HA_USER_AGENT} self.threshold = int(threshold) self.is_visible = None self.is_visible_text = None @@ -132,14 +133,14 @@ class AuroraData(object): def get_aurora_forecast(self): """Get forecast data and parse for given long/lat.""" - raw_data = requests.get(self.api_url, headers=self.headers).text + raw_data = requests.get(URL, headers=self.headers, timeout=5).text forecast_table = [ row.strip(" ").split(" ") for row in raw_data.split("\n") if not row.startswith("#") ] - # convert lat and long for data points in table + # Convert lat and long for data points in table converted_latitude = round((self.latitude / 180) * self.number_of_latitude_intervals) converted_longitude = round((self.longitude / 360) diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py index aff1c14b252..f04e0af7be9 100644 --- a/homeassistant/components/bloomsky.py +++ b/homeassistant/components/bloomsky.py @@ -4,16 +4,17 @@ Support for BloomSky weather station. For more details about this component, please refer to the documentation at https://home-assistant.io/components/bloomsky/ """ -import logging from datetime import timedelta +import logging +from aiohttp.hdrs import AUTHORIZATION import requests import voluptuous as vol from homeassistant.const import CONF_API_KEY from homeassistant.helpers import discovery -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -68,7 +69,7 @@ class BloomSky(object): """Use the API to retrieve a list of devices.""" _LOGGER.debug("Fetching BloomSky update") response = requests.get( - self.API_URL, headers={"Authorization": self._api_key}, timeout=10) + self.API_URL, headers={AUTHORIZATION: self._api_key}, timeout=10) if response.status_code == 401: raise RuntimeError("Invalid API_KEY") elif response.status_code != 200: diff --git a/homeassistant/components/device_tracker/swisscom.py b/homeassistant/components/device_tracker/swisscom.py index e64d30942ca..d5826ecedff 100644 --- a/homeassistant/components/device_tracker/swisscom.py +++ b/homeassistant/components/device_tracker/swisscom.py @@ -6,13 +6,14 @@ https://home-assistant.io/components/device_tracker.swisscom/ """ import logging +from aiohttp.hdrs import CONTENT_TYPE 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 +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -77,7 +78,7 @@ class SwisscomDeviceScanner(DeviceScanner): def get_swisscom_data(self): """Retrieve data from Swisscom and return parsed result.""" url = 'http://{}/ws'.format(self.host) - headers = {'Content-Type': 'application/x-sah-ws-4-call+json'} + headers = {CONTENT_TYPE: 'application/x-sah-ws-4-call+json'} data = """ {"service":"Devices", "method":"get", "parameters":{"expression":"lan and not self"}}""" diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index a52de48d061..6c5fb697c07 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -5,21 +5,27 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.tplink/ """ import base64 +from datetime import datetime import hashlib import logging import re -from datetime import datetime +from aiohttp.hdrs import ( + ACCEPT, COOKIE, PRAGMA, REFERER, CONNECTION, KEEP_ALIVE, USER_AGENT, + CONTENT_TYPE, CACHE_CONTROL, ACCEPT_ENCODING, ACCEPT_LANGUAGE) 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 +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_HEADER_X_REQUESTED_WITH) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +HTTP_HEADER_NO_CACHE = 'no-cache' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -78,7 +84,7 @@ class TplinkDeviceScanner(DeviceScanner): referer = 'http://{}'.format(self.host) page = requests.get( url, auth=(self.username, self.password), - headers={'referer': referer}, timeout=4) + headers={REFERER: referer}, timeout=4) result = self.parse_macs.findall(page.text) @@ -123,7 +129,7 @@ class Tplink2DeviceScanner(TplinkDeviceScanner): .format(b64_encoded_username_password) response = requests.post( - url, headers={'referer': referer, 'cookie': cookie}, + url, headers={REFERER: referer, COOKIE: cookie}, timeout=4) try: @@ -174,11 +180,11 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): .format(self.host) referer = 'http://{}/webpages/login.html'.format(self.host) - # If possible implement rsa encryption of password here. + # If possible implement RSA encryption of password here. response = requests.post( url, params={'operation': 'login', 'username': self.username, 'password': self.password}, - headers={'referer': referer}, timeout=4) + headers={REFERER: referer}, timeout=4) try: self.stok = response.json().get('data').get('stok') @@ -207,11 +213,9 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): 'form=statistics').format(self.host, self.stok) referer = 'http://{}/webpages/index.html'.format(self.host) - response = requests.post(url, - params={'operation': 'load'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}, - timeout=5) + response = requests.post( + url, params={'operation': 'load'}, headers={REFERER: referer}, + cookies={'sysauth': self.sysauth}, timeout=5) try: json_response = response.json() @@ -248,10 +252,9 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): 'form=logout').format(self.host, self.stok) referer = 'http://{}/webpages/index.html'.format(self.host) - requests.post(url, - params={'operation': 'write'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}) + requests.post( + url, params={'operation': 'write'}, headers={REFERER: referer}, + cookies={'sysauth': self.sysauth}) self.stok = '' self.sysauth = '' @@ -292,7 +295,7 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): # Create the authorization cookie. cookie = 'Authorization=Basic {}'.format(self.credentials) - response = requests.get(url, headers={'cookie': cookie}) + response = requests.get(url, headers={COOKIE: cookie}) try: result = re.search(r'window.parent.location.href = ' @@ -326,8 +329,8 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): cookie = 'Authorization=Basic {}'.format(self.credentials) page = requests.get(url, headers={ - 'cookie': cookie, - 'referer': referer + COOKIE: cookie, + REFERER: referer, }) mac_results.extend(self.parse_macs.findall(page.text)) @@ -361,31 +364,31 @@ class Tplink5DeviceScanner(TplinkDeviceScanner): base_url = 'http://{}'.format(self.host) header = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" - " rv:53.0) Gecko/20100101 Firefox/53.0", - "Accept": "application/json, text/javascript, */*; q=0.01", - "Accept-Language": "Accept-Language: en-US,en;q=0.5", - "Accept-Encoding": "gzip, deflate", - "Content-Type": "application/x-www-form-urlencoded; " - "charset=UTF-8", - "X-Requested-With": "XMLHttpRequest", - "Referer": "http://" + self.host + "/", - "Connection": "keep-alive", - "Pragma": "no-cache", - "Cache-Control": "no-cache" + USER_AGENT: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" + " rv:53.0) Gecko/20100101 Firefox/53.0", + ACCEPT: "application/json, text/javascript, */*; q=0.01", + ACCEPT_LANGUAGE: "Accept-Language: en-US,en;q=0.5", + ACCEPT_ENCODING: "gzip, deflate", + CONTENT_TYPE: "application/x-www-form-urlencoded; charset=UTF-8", + HTTP_HEADER_X_REQUESTED_WITH: "XMLHttpRequest", + REFERER: "http://{}/".format(self.host), + CONNECTION: KEEP_ALIVE, + PRAGMA: HTTP_HEADER_NO_CACHE, + CACHE_CONTROL: HTTP_HEADER_NO_CACHE, } password_md5 = hashlib.md5( self.password.encode('utf')).hexdigest().upper() - # create a session to handle cookie easier + # Create a session to handle cookie easier session = requests.session() session.get(base_url, headers=header) login_data = {"username": self.username, "password": password_md5} session.post(base_url, login_data, headers=header) - # a timestamp is required to be sent as get parameter + # A timestamp is required to be sent as get parameter timestamp = int(datetime.now().timestamp() * 1e3) client_list_url = '{}/data/monitor.client.client.json'.format( @@ -393,18 +396,17 @@ class Tplink5DeviceScanner(TplinkDeviceScanner): get_params = { 'operation': 'load', - '_': timestamp + '_': timestamp, } - response = session.get(client_list_url, - headers=header, - params=get_params) + response = session.get( + client_list_url, headers=header, params=get_params) session.close() try: list_of_devices = response.json() except ValueError: _LOGGER.error("AP didn't respond with JSON. " - "Check if credentials are correct.") + "Check if credentials are correct") return False if list_of_devices: diff --git a/homeassistant/components/device_tracker/upc_connect.py b/homeassistant/components/device_tracker/upc_connect.py index 338ce34048e..fbcd753713c 100644 --- a/homeassistant/components/device_tracker/upc_connect.py +++ b/homeassistant/components/device_tracker/upc_connect.py @@ -8,28 +8,28 @@ import asyncio import logging import aiohttp +from aiohttp.hdrs import REFERER, USER_AGENT import async_timeout 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 +from homeassistant.const import CONF_HOST, HTTP_HEADER_X_REQUESTED_WITH from homeassistant.helpers.aiohttp_client import async_get_clientsession - +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['defusedxml==0.5.0'] _LOGGER = logging.getLogger(__name__) +CMD_DEVICES = 123 + DEFAULT_IP = '192.168.0.1' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string, }) -CMD_DEVICES = 123 - @asyncio.coroutine def async_get_scanner(hass, config): @@ -52,11 +52,11 @@ class UPCDeviceScanner(DeviceScanner): self.token = None self.headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'Referer': "http://{}/index.html".format(self.host), - 'User-Agent': ("Mozilla/5.0 (Windows NT 10.0; WOW64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/47.0.2526.106 Safari/537.36") + HTTP_HEADER_X_REQUESTED_WITH: 'XMLHttpRequest', + REFERER: "http://{}/index.html".format(self.host), + USER_AGENT: ("Mozilla/5.0 (Windows NT 10.0; WOW64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/47.0.2526.106 Safari/537.36") } self.websession = async_get_clientsession(hass) @@ -95,8 +95,7 @@ class UPCDeviceScanner(DeviceScanner): with async_timeout.timeout(10, loop=self.hass.loop): response = yield from self.websession.get( "http://{}/common_page/login.html".format(self.host), - headers=self.headers - ) + headers=self.headers) yield from response.text() @@ -118,17 +117,15 @@ class UPCDeviceScanner(DeviceScanner): response = yield from self.websession.post( "http://{}/xml/getter.xml".format(self.host), data="token={}&fun={}".format(self.token, function), - headers=self.headers, - allow_redirects=False - ) + headers=self.headers, allow_redirects=False) - # error? + # Error? if response.status != 200: _LOGGER.warning("Receive http code %d", response.status) self.token = None return - # load data, store token for next request + # Load data, store token for next request self.token = response.cookies['sessionToken'].value return (yield from response.text()) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index adc626f73b7..76b911e051a 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -7,27 +7,24 @@ https://home-assistant.io/components/google_assistant/ import asyncio import logging +from typing import Any, Dict # NOQA + +from aiohttp.hdrs import AUTHORIZATION +from aiohttp.web import Request, Response # NOQA + # Typing imports # pylint: disable=using-constant-test,unused-import,ungrouped-imports # if False: +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED from homeassistant.core import HomeAssistant # NOQA -from aiohttp.web import Request, Response # NOQA -from typing import Dict, Tuple, Any # NOQA from homeassistant.helpers.entity import Entity # NOQA -from homeassistant.components.http import HomeAssistantView - -from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED) - from .const import ( - GOOGLE_ASSISTANT_API_ENDPOINT, - CONF_ACCESS_TOKEN, - DEFAULT_EXPOSE_BY_DEFAULT, - DEFAULT_EXPOSED_DOMAINS, - CONF_EXPOSE_BY_DEFAULT, - CONF_EXPOSED_DOMAINS, - ATTR_GOOGLE_ASSISTANT) -from .smart_home import entity_to_device, query_device, determine_service + CONF_ACCESS_TOKEN, CONF_EXPOSED_DOMAINS, ATTR_GOOGLE_ASSISTANT, + CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSED_DOMAINS, DEFAULT_EXPOSE_BY_DEFAULT, + GOOGLE_ASSISTANT_API_ENDPOINT) +from .smart_home import query_device, entity_to_device, determine_service _LOGGER = logging.getLogger(__name__) @@ -140,7 +137,7 @@ class GoogleAssistantView(HomeAssistantView): @asyncio.coroutine def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" - auth = request.headers.get('Authorization', None) + auth = request.headers.get(AUTHORIZATION, None) if 'Bearer {}'.format(self.access_token) != auth: return self.json_message( "missing authorization", status_code=HTTP_UNAUTHORIZED) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index f402a9d6892..5dda8f1825d 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -5,37 +5,43 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ import asyncio -import json from functools import wraps -import logging -import ssl from ipaddress import ip_network - +import json +import logging import os -import voluptuous as vol -from aiohttp import web -from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently +import ssl +from aiohttp import web +from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE +from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently +import voluptuous as vol + +from homeassistant.const import ( + SERVER_PORT, CONTENT_TYPE_JSON, HTTP_HEADER_HA_AUTH, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, + HTTP_HEADER_X_REQUESTED_WITH) +from homeassistant.core import is_callback import homeassistant.helpers.config_validation as cv import homeassistant.remote as rem import homeassistant.util as hass_util -from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS, - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -from homeassistant.core import is_callback from homeassistant.util.logging import HideSensitiveDataFilter from .auth import auth_middleware from .ban import ban_middleware from .const import ( - KEY_USE_X_FORWARDED_FOR, KEY_TRUSTED_NETWORKS, KEY_BANS_ENABLED, - KEY_LOGIN_THRESHOLD, KEY_AUTHENTICATED) + KEY_BANS_ENABLED, KEY_AUTHENTICATED, KEY_LOGIN_THRESHOLD, + KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR) from .static import ( - staticresource_middleware, CachingFileResponse, CachingStaticResource) + CachingFileResponse, CachingStaticResource, staticresource_middleware) from .util import get_real_ip REQUIREMENTS = ['aiohttp_cors==0.5.3'] +ALLOWED_CORS_HEADERS = [ + ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, + HTTP_HEADER_HA_AUTH] + DOMAIN = 'http' CONF_API_PASSWORD = 'api_password' diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f037dfb708e..e9b51874de3 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -12,27 +12,27 @@ import logging import os from random import SystemRandom -from aiohttp import web, hdrs +from aiohttp import web +from aiohttp.hdrs import CONTENT_TYPE, CACHE_CONTROL import async_timeout import voluptuous as vol +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.config import load_yaml_config_file -from homeassistant.loader import bind_hass -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa -from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED +from homeassistant.const import ( + STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, ATTR_ENTITY_ID, + SERVICE_TOGGLE, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_VOLUME_UP, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, + SERVICE_VOLUME_SET, SERVICE_MEDIA_PAUSE, SERVICE_SHUFFLE_SET, + SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.loader import bind_hass from homeassistant.util.async import run_coroutine_threadsafe -from homeassistant.const import ( - STATE_OFF, STATE_UNKNOWN, STATE_PLAYING, STATE_IDLE, - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET, - SERVICE_VOLUME_MUTE, SERVICE_TOGGLE, SERVICE_MEDIA_STOP, - SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, - SERVICE_SHUFFLE_SET) _LOGGER = logging.getLogger(__name__) _RND = SystemRandom() @@ -53,8 +53,6 @@ ENTITY_IMAGE_CACHE = { ATTR_CACHE_MAXSIZE: 16 } -CONTENT_TYPE_HEADER = 'Content-Type' - SERVICE_PLAY_MEDIA = 'play_media' SERVICE_SELECT_SOURCE = 'select_source' SERVICE_CLEAR_PLAYLIST = 'clear_playlist' @@ -911,7 +909,7 @@ def _async_fetch_image(hass, url): if response.status == 200: content = yield from response.read() - content_type = response.headers.get(CONTENT_TYPE_HEADER) + content_type = response.headers.get(CONTENT_TYPE) if content_type: content_type = content_type.split(';')[0] @@ -965,8 +963,6 @@ class MediaPlayerImageView(HomeAssistantView): if data is None: return web.Response(status=500) - headers = {hdrs.CACHE_CONTROL: 'max-age=3600'} + headers = {CACHE_CONTROL: 'max-age=3600'} return web.Response( - body=data, - content_type=content_type, - headers=headers) + body=data, content_type=content_type, headers=headers) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index c1b9bab6937..1f86056efb5 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -4,33 +4,37 @@ Bluesound. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.bluesound/ """ -import logging -from datetime import timedelta -from asyncio.futures import CancelledError import asyncio -import voluptuous as vol -from aiohttp.client_exceptions import ClientError +from asyncio.futures import CancelledError +from datetime import timedelta +import logging + import aiohttp +from aiohttp.client_exceptions import ClientError +from aiohttp.hdrs import CONNECTION, KEEP_ALIVE import async_timeout -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.core import callback -from homeassistant.util import Throttle -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.util.dt as dt_util +import voluptuous as vol from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, - SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, - SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, - SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_STEP) + SUPPORT_PLAY, SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_PAUSE, PLATFORM_SCHEMA, + MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_PREVIOUS_TRACK, + MediaPlayerDevice) from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - STATE_PLAYING, STATE_PAUSED, STATE_IDLE, CONF_HOSTS, - CONF_HOST, CONF_PORT, CONF_NAME) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_HOSTS, STATE_IDLE, STATE_PAUSED, + STATE_PLAYING, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) +from homeassistant.core import callback +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 +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util REQUIREMENTS = ['xmltodict==0.11.0'] +_LOGGER = logging.getLogger(__name__) + STATE_OFFLINE = 'offline' ATTR_MODEL = 'model' ATTR_MODEL_NAME = 'model_name' @@ -46,8 +50,6 @@ UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) NODE_OFFLINE_CHECK_TIMEOUT = 180 NODE_RETRY_INITIATION = timedelta(minutes=3) -_LOGGER = logging.getLogger(__name__) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [{ vol.Required(CONF_HOST): cv.string, @@ -80,20 +82,15 @@ def _add_player(hass, async_add_devices, host, port=None, name=None): def _add_player_cb(): """Add player after first sync fetch.""" async_add_devices([player]) - _LOGGER.info('Added Bluesound device with name: %s', player.name) + _LOGGER.info("Added device with name: %s", player.name) if hass.is_running: _start_polling() else: hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, - _start_polling - ) + EVENT_HOMEASSISTANT_START, _start_polling) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, - _stop_polling - ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) player = BluesoundPlayer(hass, host, port, name, _add_player_cb) hass.data[DATA_BLUESOUND].append(player) @@ -101,10 +98,7 @@ def _add_player(hass, async_add_devices, host, port=None, name=None): if hass.is_running: _init_player() else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, - _init_player - ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player) @asyncio.coroutine @@ -121,11 +115,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hosts = config.get(CONF_HOSTS, None) if hosts: for host in hosts: - _add_player(hass, - async_add_devices, - host.get(CONF_HOST), - host.get(CONF_PORT, None), - host.get(CONF_NAME, None)) + _add_player( + hass, async_add_devices, host.get(CONF_HOST), + host.get(CONF_PORT), host.get(CONF_NAME, None)) class BluesoundPlayer(MediaPlayerDevice): @@ -137,7 +129,7 @@ class BluesoundPlayer(MediaPlayerDevice): self._hass = hass self._port = port self._polling_session = async_get_clientsession(hass) - self._polling_task = None # The actuall polling task. + self._polling_task = None # The actual polling task. self._name = name self._brand = None self._model = None @@ -156,7 +148,6 @@ class BluesoundPlayer(MediaPlayerDevice): if self._port is None: self._port = DEFAULT_PORT -# Internal methods @staticmethod def _try_get_index(string, seach_string): try: @@ -165,13 +156,12 @@ class BluesoundPlayer(MediaPlayerDevice): return -1 @asyncio.coroutine - def _internal_update_sync_status(self, on_updated_cb=None, - raise_timeout=False): + def _internal_update_sync_status( + self, on_updated_cb=None, raise_timeout=False): resp = None try: resp = yield from self.send_bluesound_command( - 'SyncStatus', - raise_timeout, raise_timeout) + 'SyncStatus', raise_timeout, raise_timeout) except: raise @@ -193,9 +183,7 @@ class BluesoundPlayer(MediaPlayerDevice): if on_updated_cb: on_updated_cb() return True -# END Internal methods -# Poll functionality @asyncio.coroutine def _start_poll_command(self): """"Loop which polls the status of the player.""" @@ -204,14 +192,13 @@ class BluesoundPlayer(MediaPlayerDevice): yield from self.async_update_status() except (asyncio.TimeoutError, ClientError): - _LOGGER.info("Bluesound node %s is offline, retrying later", - self._name) - yield from asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT, - loop=self._hass.loop) + _LOGGER.info("Node %s is offline, retrying later", self._name) + yield from asyncio.sleep( + NODE_OFFLINE_CHECK_TIMEOUT, loop=self._hass.loop) self.start_polling() except CancelledError: - _LOGGER.debug("Stopping bluesound polling of node %s", self._name) + _LOGGER.debug("Stopping the polling of node %s", self._name) except: _LOGGER.exception("Unexpected error in %s", self._name) raise @@ -224,9 +211,7 @@ class BluesoundPlayer(MediaPlayerDevice): def stop_polling(self): """Stop the polling task.""" self._polling_task.cancel() -# END Poll functionality -# Initiator @asyncio.coroutine def async_init(self): """Initiate the player async.""" @@ -235,22 +220,17 @@ class BluesoundPlayer(MediaPlayerDevice): self._retry_remove() self._retry_remove = None - yield from self._internal_update_sync_status(self._init_callback, - True) + yield from self._internal_update_sync_status( + self._init_callback, True) except (asyncio.TimeoutError, ClientError): - _LOGGER.info("Bluesound node %s is offline, retrying later", - self.host) + _LOGGER.info("Node %s is offline, retrying later", self.host) self._retry_remove = async_track_time_interval( - self._hass, - self.async_init, - NODE_RETRY_INITIATION) + self._hass, self.async_init, NODE_RETRY_INITIATION) except: _LOGGER.exception("Unexpected when initiating error in %s", self.host) raise -# END Initiator -# Status updates fetchers @asyncio.coroutine def async_update(self): """Update internal status of the entity.""" @@ -275,7 +255,7 @@ class BluesoundPlayer(MediaPlayerDevice): method = method[1:] url = "http://{}:{}/{}".format(self.host, self._port, method) - _LOGGER.info("calling URL: %s", url) + _LOGGER.debug("Calling URL: %s", url) response = None try: websession = async_get_clientsession(self._hass) @@ -294,11 +274,10 @@ class BluesoundPlayer(MediaPlayerDevice): except (asyncio.TimeoutError, aiohttp.ClientError): if raise_timeout: - _LOGGER.info("Timeout with Bluesound: %s", self.host) + _LOGGER.info("Timeout: %s", self.host) raise else: - _LOGGER.debug("Failed communicating with Bluesound: %s", - self.host) + _LOGGER.debug("Failed communicating: %s", self.host) return None return data @@ -315,17 +294,17 @@ class BluesoundPlayer(MediaPlayerDevice): etag = self._status.get('@etag', '') if etag != '': - url = 'Status?etag='+etag+'&timeout=60.0' + url = 'Status?etag={}&timeout=60.0'.format(etag) url = "http://{}:{}/{}".format(self.host, self._port, url) - _LOGGER.debug("calling URL: %s", url) + _LOGGER.debug("Calling URL: %s", url) try: with async_timeout.timeout(65, loop=self._hass.loop): response = yield from self._polling_session.get( url, - headers={'connection': 'keep-alive'}) + headers={CONNECTION: KEEP_ALIVE}) if response.status != 200: _LOGGER.error("Error %s on %s", response.status, url) @@ -350,8 +329,8 @@ class BluesoundPlayer(MediaPlayerDevice): def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False): """Update sync status.""" - yield from self._internal_update_sync_status(on_updated_cb, - raise_timeout=False) + yield from self._internal_update_sync_status( + on_updated_cb, raise_timeout=False) @asyncio.coroutine @Throttle(UPDATE_CAPTURE_INTERVAL) @@ -436,9 +415,7 @@ class BluesoundPlayer(MediaPlayerDevice): _create_service_item(resp['services']['service']) return self._services_items -# END Status updates fetchers -# Media player (and core) properties @property def should_poll(self): """No need to poll information.""" @@ -611,17 +588,17 @@ class BluesoundPlayer(MediaPlayerDevice): stream_url = self._status.get('streamUrl', '') if self._status.get('is_preset', '') == '1' and stream_url != '': - # this check doesn't work with all presets, for example playlists. - # But it works with radio service_items will catch playlists + # This check doesn't work with all presets, for example playlists. + # But it works with radio service_items will catch playlists. items = [x for x in self._preset_items if 'url2' in x and parse.unquote(x['url2']) == stream_url] if len(items) > 0: return items[0]['title'] - # this could be a bit difficult to detect. Bluetooth could be named + # This could be a bit difficult to detect. Bluetooth could be named # different things and there is not any way to match chooses in # capture list to current playing. It's a bit of guesswork. - # This method will be needing some tweaking over time + # This method will be needing some tweaking over time. title = self._status.get('title1', '').lower() if title == 'bluetooth' or stream_url == 'Capture:hw:2,0/44100/16/2': items = [x for x in self._capture_items @@ -660,7 +637,7 @@ class BluesoundPlayer(MediaPlayerDevice): return items[0]['title'] if self._status.get('streamUrl', '') != '': - _LOGGER.debug("Couldn't find source of stream url: %s", + _LOGGER.debug("Couldn't find source of stream URL: %s", self._status.get('streamUrl', '')) return None @@ -695,9 +672,7 @@ class BluesoundPlayer(MediaPlayerDevice): ATTR_MODEL_NAME: self._model_name, ATTR_BRAND: self._brand, } -# END Media player (and core) properties -# Media player commands @asyncio.coroutine def async_select_source(self, source): """Select input source.""" @@ -712,8 +687,8 @@ class BluesoundPlayer(MediaPlayerDevice): return selected_source = items[0] - url = 'Play?url={}&preset_id&image={}'.format(selected_source['url'], - selected_source['image']) + url = 'Play?url={}&preset_id&image={}'.format( + selected_source['url'], selected_source['image']) if 'is_raw_url' in selected_source and selected_source['is_raw_url']: url = selected_source['url'] @@ -806,4 +781,3 @@ class BluesoundPlayer(MediaPlayerDevice): else: return self.send_bluesound_command( 'Volume?level=' + str(float(self._lastvol) * 100)) -# END Media player commands diff --git a/homeassistant/components/no_ip.py b/homeassistant/components/no_ip.py index 48bd681ac62..6051fa85f55 100644 --- a/homeassistant/components/no_ip.py +++ b/homeassistant/components/no_ip.py @@ -6,18 +6,18 @@ https://home-assistant.io/components/no_ip/ """ import asyncio import base64 -import logging from datetime import timedelta +import logging import aiohttp +from aiohttp.hdrs import USER_AGENT, AUTHORIZATION import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, HTTP_HEADER_AUTH, - HTTP_HEADER_USER_AGENT) + CONF_DOMAIN, CONF_TIMEOUT, CONF_PASSWORD, CONF_USERNAME) from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,7 @@ NO_IP_ERRORS = { } UPDATE_URL = 'https://dynupdate.noip.com/nic/update' -USER_AGENT = "{} {}".format(SERVER_SOFTWARE, EMAIL) +HA_USER_AGENT = "{} {}".format(SERVER_SOFTWARE, EMAIL) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -92,8 +92,8 @@ def _update_no_ip(hass, session, domain, auth_str, timeout): } headers = { - HTTP_HEADER_AUTH: "Basic {}".format(auth_str.decode('utf-8')), - HTTP_HEADER_USER_AGENT: USER_AGENT, + AUTHORIZATION: "Basic {}".format(auth_str.decode('utf-8')), + USER_AGENT: HA_USER_AGENT, } try: diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index 663f689a975..543ce434a8d 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -6,22 +6,22 @@ https://home-assistant.io/components/notify.clicksend/ """ import json import logging -import requests +from aiohttp.hdrs import CONTENT_TYPE +import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_USERNAME, CONF_API_KEY, CONF_RECIPIENT, HTTP_HEADER_CONTENT_TYPE, - CONTENT_TYPE_JSON) from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import ( + CONF_API_KEY, CONF_USERNAME, CONF_RECIPIENT, CONTENT_TYPE_JSON) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' -HEADERS = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} +HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, diff --git a/homeassistant/components/notify/clicksend_tts.py b/homeassistant/components/notify/clicksend_tts.py index f951dd00307..26a29993290 100644 --- a/homeassistant/components/notify/clicksend_tts.py +++ b/homeassistant/components/notify/clicksend_tts.py @@ -8,22 +8,22 @@ https://home-assistant.io/components/notify.clicksend_tts/ """ import json import logging -import requests +from aiohttp.hdrs import CONTENT_TYPE +import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_USERNAME, CONF_API_KEY, CONF_RECIPIENT, HTTP_HEADER_CONTENT_TYPE, - CONTENT_TYPE_JSON) from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import ( + CONF_API_KEY, CONF_USERNAME, CONF_RECIPIENT, CONTENT_TYPE_JSON) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' -HEADERS = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} +HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} CONF_LANGUAGE = 'language' CONF_VOICE = 'voice' diff --git a/homeassistant/components/notify/facebook.py b/homeassistant/components/notify/facebook.py index db175c6b0a6..791440fdb5b 100644 --- a/homeassistant/components/notify/facebook.py +++ b/homeassistant/components/notify/facebook.py @@ -6,14 +6,14 @@ https://home-assistant.io/components/notify.facebook/ """ import logging +from aiohttp.hdrs import CONTENT_TYPE import requests - import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) + ATTR_DATA, ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -70,7 +70,7 @@ class FacebookNotificationService(BaseNotificationService): import json resp = requests.post(BASE_URL, data=json.dumps(body), params=payload, - headers={'Content-Type': CONTENT_TYPE_JSON}, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, timeout=10) if resp.status_code != 200: obj = resp.json() diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 1b44ec60722..cb81ef55865 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -5,25 +5,26 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.html5/ """ import asyncio -import os -import logging -import json -import time import datetime +import json +import logging +import os +import time import uuid +from aiohttp.hdrs import AUTHORIZATION import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, - HTTP_UNAUTHORIZED, URL_ROOT) -from homeassistant.util import ensure_unique_string -from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, - BaseNotificationService, PLATFORM_SCHEMA) -from homeassistant.components.http import HomeAssistantView from homeassistant.components.frontend import add_manifest_json_key +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.notify import ( + ATTR_DATA, ATTR_TITLE, ATTR_TARGET, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, + BaseNotificationService) +from homeassistant.const import ( + URL_ROOT, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, HTTP_INTERNAL_SERVER_ERROR) from homeassistant.helpers import config_validation as cv +from homeassistant.util import ensure_unique_string REQUIREMENTS = ['pywebpush==1.1.0', 'PyJWT==1.5.3'] @@ -62,24 +63,25 @@ ATTR_JWT = 'jwt' # is valid. JWT_VALID_DAYS = 7 -KEYS_SCHEMA = vol.All(dict, - vol.Schema({ - vol.Required(ATTR_AUTH): cv.string, - vol.Required(ATTR_P256DH): cv.string - })) +KEYS_SCHEMA = vol.All( + dict, vol.Schema({ + vol.Required(ATTR_AUTH): cv.string, + vol.Required(ATTR_P256DH): cv.string, + }) +) -SUBSCRIPTION_SCHEMA = vol.All(dict, - vol.Schema({ - # pylint: disable=no-value-for-parameter - vol.Required(ATTR_ENDPOINT): vol.Url(), - vol.Required(ATTR_KEYS): KEYS_SCHEMA, - vol.Optional(ATTR_EXPIRATIONTIME): - vol.Any(None, cv.positive_int) - })) +SUBSCRIPTION_SCHEMA = vol.All( + dict, vol.Schema({ + # pylint: disable=no-value-for-parameter + vol.Required(ATTR_ENDPOINT): vol.Url(), + vol.Required(ATTR_KEYS): KEYS_SCHEMA, + vol.Optional(ATTR_EXPIRATIONTIME): vol.Any(None, cv.positive_int), + }) +) REGISTER_SCHEMA = vol.Schema({ vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA, - vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']) + vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']), }) CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema({ @@ -145,7 +147,7 @@ class JSONBytesDecoder(json.JSONEncoder): # pylint: disable=method-hidden def default(self, obj): - """Decode object if it's a bytes object, else defer to baseclass.""" + """Decode object if it's a bytes object, else defer to base class.""" if isinstance(obj, bytes): return obj.decode() return json.JSONEncoder.default(self, obj) @@ -158,7 +160,7 @@ def _save_config(filename, config): fdesc.write(json.dumps( config, cls=JSONBytesDecoder, indent=4, sort_keys=True)) except (IOError, TypeError) as error: - _LOGGER.error("Saving config file failed: %s", error) + _LOGGER.error("Saving configuration file failed: %s", error) return False return True @@ -266,7 +268,7 @@ class HTML5PushCallbackView(HomeAssistantView): def check_authorization_header(self, request): """Check the authorization header.""" import jwt - auth = request.headers.get('Authorization', None) + auth = request.headers.get(AUTHORIZATION, None) if not auth: return self.json_message('Authorization header is expected', status_code=HTTP_UNAUTHORIZED) @@ -323,8 +325,7 @@ class HTML5PushCallbackView(HomeAssistantView): event_name = '{}.{}'.format(NOTIFY_CALLBACK_EVENT, event_payload[ATTR_TYPE]) request.app['hass'].bus.fire(event_name, event_payload) - return self.json({'status': 'ok', - 'event': event_payload[ATTR_TYPE]}) + return self.json({'status': 'ok', 'event': event_payload[ATTR_TYPE]}) class HTML5NotificationService(BaseNotificationService): @@ -413,6 +414,6 @@ class HTML5NotificationService(BaseNotificationService): if not _save_config(self.registrations_json_path, self.registrations): self.registrations[target] = reg - _LOGGER.error("Error saving registration.") + _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py index 39cdf0fc475..e792045ec80 100644 --- a/homeassistant/components/notify/instapush.py +++ b/homeassistant/components/notify/instapush.py @@ -7,14 +7,14 @@ https://home-assistant.io/components/notify.instapush/ import json import logging +from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import ( - CONF_API_KEY, HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON) + ATTR_TITLE, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, BaseNotificationService) +from homeassistant.const import CONF_API_KEY, CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://api.instapush.im/v1/' @@ -76,7 +76,7 @@ class InstapushNotificationService(BaseNotificationService): self._headers = { HTTP_HEADER_APPID: self._api_key, HTTP_HEADER_APPSECRET: self._app_secret, - HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, + CONTENT_TYPE: CONTENT_TYPE_JSON, } def send_message(self, message="", **kwargs): diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index b0185218846..89117397a53 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -10,7 +10,8 @@ import voluptuous as vol from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) +from homeassistant.const import ( + CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT, CONTENT_TYPE_TEXT_PLAIN) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['sendgrid==5.3.0'] @@ -67,7 +68,7 @@ class SendgridNotificationService(BaseNotificationService): }, "content": [ { - "type": "text/plain", + "type": CONTENT_TYPE_TEXT_PLAIN, "value": message } ] diff --git a/homeassistant/components/notify/telstra.py b/homeassistant/components/notify/telstra.py index 7fabb51eac8..82ac914a647 100644 --- a/homeassistant/components/notify/telstra.py +++ b/homeassistant/components/notify/telstra.py @@ -6,12 +6,13 @@ https://home-assistant.io/components/notify.telstra/ """ import logging +from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION import requests import voluptuous as vol from homeassistant.components.notify import ( - BaseNotificationService, ATTR_TITLE, PLATFORM_SCHEMA) -from homeassistant.const import CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE + ATTR_TITLE, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONTENT_TYPE_JSON import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -73,8 +74,8 @@ class TelstraNotificationService(BaseNotificationService): } message_resource = 'https://api.telstra.com/v1/sms/messages' message_headers = { - HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, - 'Authorization': 'Bearer ' + token_response['access_token'], + CONTENT_TYPE: CONTENT_TYPE_JSON, + AUTHORIZATION: 'Bearer {}'.format(token_response['access_token']), } message_response = requests.post( message_resource, headers=message_headers, json=message_data, diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index fdf237d7180..086242ab070 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -9,6 +9,7 @@ import time import requests import voluptuous as vol +from aiohttp.hdrs import CONTENT_TYPE from homeassistant.const import CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON import homeassistant.helpers.config_validation as cv @@ -55,8 +56,10 @@ class OctoPrintAPI(object): def __init__(self, api_url, key, bed, number_of_tools): """Initialize OctoPrint API and set headers needed later.""" self.api_url = api_url - self.headers = {'content-type': CONTENT_TYPE_JSON, - 'X-Api-Key': key} + self.headers = { + CONTENT_TYPE: CONTENT_TYPE_JSON, + 'X-Api-Key': key, + } self.printer_last_reading = [{}, None] self.job_last_reading = [{}, None] self.job_available = False diff --git a/homeassistant/components/scene/lifx_cloud.py b/homeassistant/components/scene/lifx_cloud.py index e6f5be71a80..ffbb10cba4e 100644 --- a/homeassistant/components/scene/lifx_cloud.py +++ b/homeassistant/components/scene/lifx_cloud.py @@ -7,15 +7,15 @@ https://home-assistant.io/components/scene.lifx_cloud/ import asyncio import logging +import aiohttp +from aiohttp.hdrs import AUTHORIZATION +import async_timeout import voluptuous as vol -import aiohttp -import async_timeout - from homeassistant.components.scene import Scene -from homeassistant.const import (CONF_PLATFORM, CONF_TOKEN, CONF_TIMEOUT) +from homeassistant.const import CONF_TOKEN, CONF_TIMEOUT, CONF_PLATFORM +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.aiohttp_client import (async_get_clientsession) _LOGGER = logging.getLogger(__name__) @@ -37,7 +37,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): timeout = config.get(CONF_TIMEOUT) headers = { - "Authorization": "Bearer %s" % token, + AUTHORIZATION: "Bearer {}".format(token), } url = LIFX_API_URL.format('scenes') diff --git a/homeassistant/components/scene/lutron_caseta.py b/homeassistant/components/scene/lutron_caseta.py index b98f7f3e6ea..066be8c9d75 100644 --- a/homeassistant/components/scene/lutron_caseta.py +++ b/homeassistant/components/scene/lutron_caseta.py @@ -6,8 +6,8 @@ https://home-assistant.io/components/scene.lutron_caseta/ """ import logging -from homeassistant.components.scene import Scene from homeassistant.components.lutron_caseta import LUTRON_CASETA_SMARTBRIDGE +from homeassistant.components.scene import Scene _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/haveibeenpwned.py b/homeassistant/components/sensor/haveibeenpwned.py index 1c28db9a9df..3b041127a5b 100644 --- a/homeassistant/components/sensor/haveibeenpwned.py +++ b/homeassistant/components/sensor/haveibeenpwned.py @@ -7,24 +7,28 @@ https://home-assistant.io/components/sensor.haveibeenpwned/ from datetime import timedelta import logging -import voluptuous as vol +from aiohttp.hdrs import USER_AGENT import requests +import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (STATE_UNKNOWN, CONF_EMAIL) -from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_EMAIL import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_point_in_time from homeassistant.util import Throttle import homeassistant.util.dt as dt_util -from homeassistant.helpers.event import track_point_in_time _LOGGER = logging.getLogger(__name__) DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" -USER_AGENT = "Home Assistant HaveIBeenPwned Sensor Component" -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +HA_USER_AGENT = "Home Assistant HaveIBeenPwned Sensor Component" + MIN_TIME_BETWEEN_FORCED_UPDATES = timedelta(seconds=5) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +URL = 'https://haveibeenpwned.com/api/v2/breachedaccount/' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_EMAIL): vol.All(cv.ensure_list, [cv.string]), @@ -33,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the HaveIBeenPwnedSensor sensor.""" + """Set up the HaveIBeenPwned sensor.""" emails = config.get(CONF_EMAIL) data = HaveIBeenPwnedData(emails) @@ -50,11 +54,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class HaveIBeenPwnedSensor(Entity): - """Implementation of a HaveIBeenPwnedSensor.""" + """Implementation of a HaveIBeenPwned sensor.""" def __init__(self, data, hass, email): - """Initialize the HaveIBeenPwnedSensor sensor.""" - self._state = STATE_UNKNOWN + """Initialize the HaveIBeenPwned sensor.""" + self._state = None self._data = data self._hass = hass self._email = email @@ -77,7 +81,7 @@ class HaveIBeenPwnedSensor(Entity): @property def device_state_attributes(self): - """Return the atrributes of the sensor.""" + """Return the attributes of the sensor.""" val = {} if self._email not in self._data.data: return val @@ -143,17 +147,16 @@ class HaveIBeenPwnedData(object): def update(self, **kwargs): """Get the latest data for current email from REST service.""" try: - url = "https://haveibeenpwned.com/api/v2/breachedaccount/{}". \ - format(self._email) + url = "{}{}".format(URL, self._email) - _LOGGER.info("Checking for breaches for email %s", self._email) + _LOGGER.debug("Checking for breaches for email: %s", self._email) - req = requests.get(url, headers={"User-agent": USER_AGENT}, - allow_redirects=True, timeout=5) + req = requests.get( + url, headers={USER_AGENT: HA_USER_AGENT}, allow_redirects=True, + timeout=5) except requests.exceptions.RequestException: - _LOGGER.error("Failed fetching HaveIBeenPwned Data for %s", - self._email) + _LOGGER.error("Failed fetching data for %s", self._email) return if req.status_code == 200: @@ -161,7 +164,7 @@ class HaveIBeenPwnedData(object): key=lambda k: k["AddedDate"], reverse=True) - # only goto next email if we had data so that + # Only goto next email if we had data so that # the forced updates try this current email again self.set_next_email() @@ -173,6 +176,6 @@ class HaveIBeenPwnedData(object): self.set_next_email() else: - _LOGGER.error("Failed fetching HaveIBeenPwned Data for %s" + _LOGGER.error("Failed fetching data for %s" "(HTTP Status_code = %d)", self._email, req.status_code) diff --git a/homeassistant/components/sensor/nzbget.py b/homeassistant/components/sensor/nzbget.py index a440074b81b..b140d02af04 100644 --- a/homeassistant/components/sensor/nzbget.py +++ b/homeassistant/components/sensor/nzbget.py @@ -4,19 +4,20 @@ Support for monitoring NZBGet NZB client. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.nzbget/ """ -import logging from datetime import timedelta +import logging +from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, - CONF_SSL, CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) + CONF_SSL, CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, + CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -145,7 +146,7 @@ class NZBGetAPI(object): """Initialize NZBGet API and set headers needed later.""" self.api_url = api_url self.status = None - self.headers = {'content-type': CONTENT_TYPE_JSON} + self.headers = {CONTENT_TYPE: CONTENT_TYPE_JSON} if username is not None and password is not None: self.auth = (username, password) @@ -155,7 +156,7 @@ class NZBGetAPI(object): def post(self, method, params=None): """Send a POST request and return the response as a dict.""" - payload = {"method": method} + payload = {'method': method} if params: payload['params'] = params diff --git a/homeassistant/components/sensor/pyload.py b/homeassistant/components/sensor/pyload.py index f9c6f2944c6..9e1c0875169 100644 --- a/homeassistant/components/sensor/pyload.py +++ b/homeassistant/components/sensor/pyload.py @@ -4,18 +4,18 @@ Support for monitoring pyLoad. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.pyload/ """ -import logging from datetime import timedelta +import logging +from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, - CONF_SSL, HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON, - CONF_MONITORED_VARIABLES) + CONF_SSL, CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, + CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -132,7 +132,7 @@ class PyLoadAPI(object): """Initialize pyLoad API and set headers needed later.""" self.api_url = api_url self.status = None - self.headers = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} + self.headers = {CONTENT_TYPE: CONTENT_TYPE_JSON} if username is not None and password is not None: self.payload = {'username': username, 'password': password} diff --git a/homeassistant/components/sensor/thethingsnetwork.py b/homeassistant/components/sensor/thethingsnetwork.py index 90b21cc19e5..28a3b48892b 100644 --- a/homeassistant/components/sensor/thethingsnetwork.py +++ b/homeassistant/components/sensor/thethingsnetwork.py @@ -8,15 +8,16 @@ import asyncio import logging import aiohttp +from aiohttp.hdrs import ACCEPT, AUTHORIZATION import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.components.thethingsnetwork import ( DATA_TTN, TTN_APP_ID, TTN_ACCESS_KEY, TTN_DATA_STORAGE_URL) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -122,8 +123,8 @@ class TtnDataStorage(object): self._url = TTN_DATA_STORAGE_URL.format( app_id=app_id, endpoint='api/v2/query', device_id=device_id) self._headers = { - 'Accept': CONTENT_TYPE_JSON, - 'Authorization': 'key {}'.format(access_key), + ACCEPT: CONTENT_TYPE_JSON, + AUTHORIZATION: 'key {}'.format(access_key), } @asyncio.coroutine diff --git a/homeassistant/components/sensor/zamg.py b/homeassistant/components/sensor/zamg.py index 3eb677b4f02..4b63d769243 100644 --- a/homeassistant/components/sensor/zamg.py +++ b/homeassistant/components/sensor/zamg.py @@ -5,24 +5,25 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.zamg/ """ import csv +from datetime import datetime, timedelta import gzip import json import logging import os -from datetime import datetime, timedelta +from aiohttp.hdrs import USER_AGENT import pytz import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.weather import ( - ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, - ATTR_WEATHER_WIND_SPEED) + ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_WIND_BEARING) from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, CONF_NAME, __version__, - CONF_LATITUDE, CONF_LONGITUDE) + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + __version__) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -30,13 +31,12 @@ _LOGGER = logging.getLogger(__name__) ATTR_STATION = 'station' ATTR_UPDATED = 'updated' -ATTRIBUTION = 'Data provided by ZAMG' +ATTRIBUTION = "Data provided by ZAMG" CONF_STATION_ID = 'station_id' DEFAULT_NAME = 'zamg' -# Data source updates once per hour, so we do nothing if it's been less time MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) SENSOR_TYPES = { @@ -138,7 +138,7 @@ class ZamgData(object): API_URL = 'http://www.zamg.ac.at/ogd/' API_HEADERS = { - 'User-Agent': '{} {}'.format('home-assistant.zamg/', __version__), + USER_AGENT: '{} {}'.format('home-assistant.zamg/', __version__), } def __init__(self, station_id): @@ -162,8 +162,8 @@ class ZamgData(object): cls.API_URL, headers=cls.API_HEADERS, timeout=15) response.raise_for_status() response.encoding = 'UTF8' - return csv.DictReader(response.text.splitlines(), - delimiter=';', quotechar='"') + return csv.DictReader( + response.text.splitlines(), delimiter=';', quotechar='"') except requests.exceptions.HTTPError: _LOGGER.error("While fetching data") diff --git a/homeassistant/components/splunk.py b/homeassistant/components/splunk.py index 38f8a91a917..a5b42eb9b5a 100644 --- a/homeassistant/components/splunk.py +++ b/homeassistant/components/splunk.py @@ -7,11 +7,12 @@ https://home-assistant.io/components/splunk/ import json import logging +from aiohttp.hdrs import AUTHORIZATION import requests import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, CONF_SSL, CONF_TOKEN, EVENT_STATE_CHANGED) + CONF_SSL, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TOKEN, EVENT_STATE_CHANGED) from homeassistant.helpers import state as state_helper import homeassistant.helpers.config_validation as cv from homeassistant.remote import JSONEncoder @@ -52,7 +53,7 @@ def setup(hass, config): event_collector = '{}{}:{}/services/collector/event'.format( uri_scheme, host, port) - headers = {'Authorization': 'Splunk {}'.format(token)} + headers = {AUTHORIZATION: 'Splunk {}'.format(token)} def splunk_event_listener(event): """Listen for new messages on the bus and sends them to Splunk.""" diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 4e26dfe3238..d94bbddffab 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -10,6 +10,7 @@ import logging import async_timeout from aiohttp.client_exceptions import ClientError +from aiohttp.hdrs import CONNECTION, KEEP_ALIVE from homeassistant.components.telegram_bot import ( CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, @@ -41,20 +42,14 @@ def async_setup_platform(hass, config): """Stop the bot.""" pol.stop_polling() - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, - _start_bot - ) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, - _stop_bot - ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_bot) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_bot) return True class TelegramPoll(BaseTelegramBotEntity): - """asyncio telegram incoming message handler.""" + """Asyncio telegram incoming message handler.""" def __init__(self, bot, hass, allowed_chat_ids): """Initialize the polling instance.""" @@ -62,9 +57,9 @@ class TelegramPoll(BaseTelegramBotEntity): self.update_id = 0 self.websession = async_get_clientsession(hass) self.update_url = '{0}/getUpdates'.format(bot.base_url) - self.polling_task = None # The actuall polling task. + self.polling_task = None # The actual polling task. self.timeout = 15 # async post timeout - # polling timeout should always be less than async post timeout. + # Polling timeout should always be less than async post timeout. self.post_data = {'timeout': self.timeout - 5} def start_polling(self): @@ -87,7 +82,7 @@ class TelegramPoll(BaseTelegramBotEntity): with async_timeout.timeout(self.timeout, loop=self.hass.loop): resp = yield from self.websession.post( self.update_url, data=self.post_data, - headers={'connection': 'keep-alive'} + headers={CONNECTION: KEEP_ALIVE} ) if resp.status == 200: _json = yield from resp.json() diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 3ddcc5c716a..4551a792fc6 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -9,14 +9,15 @@ import logging import re import aiohttp +from aiohttp.hdrs import REFERER, USER_AGENT import async_timeout import voluptuous as vol import yarl -from homeassistant.components.tts import Provider, PLATFORM_SCHEMA, CONF_LANG +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ["gTTS-token==1.1.1"] +REQUIREMENTS = ['gTTS-token==1.1.1'] _LOGGER = logging.getLogger(__name__) @@ -52,10 +53,10 @@ class GoogleProvider(Provider): self.hass = hass self._lang = lang self.headers = { - 'Referer': "http://translate.google.com/", - 'User-Agent': ("Mozilla/5.0 (Windows NT 10.0; WOW64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/47.0.2526.106 Safari/537.36") + REFERER: "http://translate.google.com/", + USER_AGENT: ("Mozilla/5.0 (Windows NT 10.0; WOW64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/47.0.2526.106 Safari/537.36"), } self.name = 'Google' diff --git a/homeassistant/const.py b/homeassistant/const.py index f9a1ed13e22..bff2adae969 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -398,24 +398,7 @@ HTTP_BASIC_AUTHENTICATION = 'basic' HTTP_DIGEST_AUTHENTICATION = 'digest' HTTP_HEADER_HA_AUTH = 'X-HA-access' -HTTP_HEADER_ACCEPT_ENCODING = 'Accept-Encoding' -HTTP_HEADER_AUTH = 'Authorization' -HTTP_HEADER_USER_AGENT = 'User-Agent' -HTTP_HEADER_CONTENT_TYPE = 'Content-type' -HTTP_HEADER_CONTENT_ENCODING = 'Content-Encoding' -HTTP_HEADER_VARY = 'Vary' -HTTP_HEADER_CONTENT_LENGTH = 'Content-Length' -HTTP_HEADER_CACHE_CONTROL = 'Cache-Control' -HTTP_HEADER_EXPIRES = 'Expires' -HTTP_HEADER_ORIGIN = 'Origin' HTTP_HEADER_X_REQUESTED_WITH = 'X-Requested-With' -HTTP_HEADER_ACCEPT = 'Accept' -HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = 'Access-Control-Allow-Origin' -HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS = 'Access-Control-Allow-Headers' - -ALLOWED_CORS_HEADERS = [HTTP_HEADER_ORIGIN, HTTP_HEADER_ACCEPT, - HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_CONTENT_TYPE, - HTTP_HEADER_HA_AUTH] CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_MULTIPART = 'multipart/x-mixed-replace; boundary={}' diff --git a/homeassistant/remote.py b/homeassistant/remote.py index c8fe62f64d9..7d032303548 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -15,22 +15,18 @@ import urllib.parse from typing import Optional +from aiohttp.hdrs import METH_GET, METH_POST, METH_DELETE, CONTENT_TYPE import requests from homeassistant import core as ha from homeassistant.const import ( - HTTP_HEADER_HA_AUTH, SERVER_PORT, URL_API, - URL_API_EVENTS, URL_API_EVENTS_EVENT, URL_API_SERVICES, URL_API_CONFIG, - URL_API_SERVICES_SERVICE, URL_API_STATES, URL_API_STATES_ENTITY, - HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON) + URL_API, SERVER_PORT, URL_API_CONFIG, URL_API_EVENTS, URL_API_STATES, + URL_API_SERVICES, CONTENT_TYPE_JSON, HTTP_HEADER_HA_AUTH, + URL_API_EVENTS_EVENT, URL_API_STATES_ENTITY, URL_API_SERVICES_SERVICE) from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) -METHOD_GET = 'get' -METHOD_POST = 'post' -METHOD_DELETE = 'delete' - class APIStatus(enum.Enum): """Representation of an API status.""" @@ -67,9 +63,7 @@ class API(object): self.base_url += ':{}'.format(port) self.status = None - self._headers = { - HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, - } + self._headers = {CONTENT_TYPE: CONTENT_TYPE_JSON} if api_password is not None: self._headers[HTTP_HEADER_HA_AUTH] = api_password @@ -89,7 +83,7 @@ class API(object): url = urllib.parse.urljoin(self.base_url, path) try: - if method == METHOD_GET: + if method == METH_GET: return requests.get( url, params=data, timeout=timeout, headers=self._headers) @@ -144,7 +138,7 @@ class JSONEncoder(json.JSONEncoder): def validate_api(api): """Make a call to validate API.""" try: - req = api(METHOD_GET, URL_API) + req = api(METH_GET, URL_API) if req.status_code == 200: return APIStatus.OK @@ -161,7 +155,7 @@ def validate_api(api): def get_event_listeners(api): """List of events that is being listened for.""" try: - req = api(METHOD_GET, URL_API_EVENTS) + req = api(METH_GET, URL_API_EVENTS) return req.json() if req.status_code == 200 else {} @@ -175,7 +169,7 @@ def get_event_listeners(api): def fire_event(api, event_type, data=None): """Fire an event at remote API.""" try: - req = api(METHOD_POST, URL_API_EVENTS_EVENT.format(event_type), data) + req = api(METH_POST, URL_API_EVENTS_EVENT.format(event_type), data) if req.status_code != 200: _LOGGER.error("Error firing event: %d - %s", @@ -188,7 +182,7 @@ def fire_event(api, event_type, data=None): def get_state(api, entity_id): """Query given API for state of entity_id.""" try: - req = api(METHOD_GET, URL_API_STATES_ENTITY.format(entity_id)) + req = api(METH_GET, URL_API_STATES_ENTITY.format(entity_id)) # req.status_code == 422 if entity does not exist @@ -205,7 +199,7 @@ def get_state(api, entity_id): def get_states(api): """Query given API for all states.""" try: - req = api(METHOD_GET, + req = api(METH_GET, URL_API_STATES) return [ha.State.from_dict(item) for @@ -224,7 +218,7 @@ def remove_state(api, entity_id): Return True if entity is gone (removed/never existed). """ try: - req = api(METHOD_DELETE, URL_API_STATES_ENTITY.format(entity_id)) + req = api(METH_DELETE, URL_API_STATES_ENTITY.format(entity_id)) if req.status_code in (200, 404): return True @@ -250,9 +244,7 @@ def set_state(api, entity_id, new_state, attributes=None, force_update=False): 'force_update': force_update} try: - req = api(METHOD_POST, - URL_API_STATES_ENTITY.format(entity_id), - data) + req = api(METH_POST, URL_API_STATES_ENTITY.format(entity_id), data) if req.status_code not in (200, 201): _LOGGER.error("Error changing state: %d - %s", @@ -280,7 +272,7 @@ def get_services(api): Each dict has a string "domain" and a list of strings "services". """ try: - req = api(METHOD_GET, URL_API_SERVICES) + req = api(METH_GET, URL_API_SERVICES) return req.json() if req.status_code == 200 else {} @@ -294,7 +286,7 @@ def get_services(api): def call_service(api, domain, service, service_data=None, timeout=5): """Call a service at the remote API.""" try: - req = api(METHOD_POST, + req = api(METH_POST, URL_API_SERVICES_SERVICE.format(domain, service), service_data, timeout=timeout) @@ -309,7 +301,7 @@ def call_service(api, domain, service, service_data=None, timeout=5): def get_config(api): """Return configuration.""" try: - req = api(METHOD_GET, URL_API_CONFIG) + req = api(METH_GET, URL_API_CONFIG) if req.status_code != 200: return {} diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index cc03324a638..383b4f7165d 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1,33 +1,32 @@ """The tests for the emulated Hue component.""" import asyncio import json - from unittest.mock import patch -import pytest -from homeassistant import setup, const, core +from aiohttp.hdrs import CONTENT_TYPE +import pytest +from tests.common import get_test_instance_port + +from homeassistant import core, const, setup import homeassistant.components as core_components from homeassistant.components import ( - emulated_hue, http, light, script, media_player, fan -) -from homeassistant.const import STATE_ON, STATE_OFF -from homeassistant.components.emulated_hue.hue_api import ( - HUE_API_STATE_ON, HUE_API_STATE_BRI, HueUsernameView, - HueAllLightsStateView, HueOneLightStateView, HueOneLightChangeView) + fan, http, light, script, emulated_hue, media_player) from homeassistant.components.emulated_hue import Config - -from tests.common import get_test_instance_port +from homeassistant.components.emulated_hue.hue_api import ( + HUE_API_STATE_ON, HUE_API_STATE_BRI, HueUsernameView, HueOneLightStateView, + HueAllLightsStateView, HueOneLightChangeView) +from homeassistant.const import STATE_ON, STATE_OFF HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}' -JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON} +JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON} @pytest.fixture def hass_hue(loop, hass): - """Setup a hass instance for these tests.""" + """Setup a Home Assistant instance for these tests.""" # We need to do this to get access to homeassistant/turn_(on,off) loop.run_until_complete( core_components.async_setup(hass, {core.DOMAIN: {}})) diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index 3706ce224be..1cd895954de 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -4,6 +4,7 @@ import json import unittest from unittest.mock import patch import requests +from aiohttp.hdrs import CONTENT_TYPE from homeassistant import setup, const, core import homeassistant.components as core_components @@ -16,7 +17,7 @@ HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}' -JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON} +JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON} def setup_hass_instance(emulated_hue_config): diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 35be79469a9..7ad59779f94 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,18 +1,18 @@ -"""The tests for the Google Actions component.""" +"""The tests for the Google Assistant component.""" # pylint: disable=protected-access -import json import asyncio -import pytest +import json -from homeassistant import setup, const, core -from homeassistant.components import ( - http, async_setup, light, cover, media_player, fan, switch, climate -) -from homeassistant.components import google_assistant as ga +from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION +import pytest from tests.common import get_test_instance_port -from . import DEMO_DEVICES +from homeassistant import core, const, setup +from homeassistant.components import ( + fan, http, cover, light, switch, climate, async_setup, media_player) +from homeassistant.components import google_assistant as ga +from . import DEMO_DEVICES API_PASSWORD = "test1234" SERVER_PORT = get_test_instance_port() @@ -20,7 +20,7 @@ BASE_API_URL = "http://127.0.0.1:{}".format(SERVER_PORT) HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, + CONTENT_TYPE: const.CONTENT_TYPE_JSON, } AUTHCFG = { @@ -28,12 +28,12 @@ AUTHCFG = { 'client_id': 'helloworld', 'access_token': 'superdoublesecret' } -AUTH_HEADER = {'Authorization': 'Bearer {}'.format(AUTHCFG['access_token'])} +AUTH_HEADER = {AUTHORIZATION: 'Bearer {}'.format(AUTHCFG['access_token'])} @pytest.fixture def assistant_client(loop, hass_fixture, test_client): - """Create web client for emulated hue api.""" + """Create web client for the Google Assistant API.""" hass = hass_fixture web_app = hass.http.app @@ -45,7 +45,7 @@ def assistant_client(loop, hass_fixture, test_client): @pytest.fixture def hass_fixture(loop, hass): - """Set up a hass instance for these tests.""" + """Set up a HOme Assistant instance for these tests.""" # We need to do this to get access to homeassistant/turn_(on,off) loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}})) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4428b5043fd..f547306ff82 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,19 +1,23 @@ """The tests for the Home Assistant HTTP component.""" import asyncio + +from aiohttp.hdrs import ( + ORIGIN, ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_ALLOW_HEADERS, + ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS, + CONTENT_TYPE) import requests - -from homeassistant import setup, const -import homeassistant.components.http as http - from tests.common import get_test_instance_port, get_test_home_assistant +from homeassistant import const, setup +import homeassistant.components.http as http + API_PASSWORD = 'test1234' SERVER_PORT = get_test_instance_port() HTTP_BASE = '127.0.0.1:{}'.format(SERVER_PORT) HTTP_BASE_URL = 'http://{}'.format(HTTP_BASE) HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, + CONTENT_TYPE: const.CONTENT_TYPE_JSON, } CORS_ORIGINS = [HTTP_BASE_URL, HTTP_BASE] @@ -64,9 +68,9 @@ class TestCors: """Test cross origin resource sharing with password in url.""" req = requests.get(_url(const.URL_API), params={'api_password': API_PASSWORD}, - headers={const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL}) + headers={ORIGIN: HTTP_BASE_URL}) - allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL @@ -75,11 +79,11 @@ class TestCors: """Test cross origin resource sharing with password in header.""" headers = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL + ORIGIN: HTTP_BASE_URL } req = requests.get(_url(const.URL_API), headers=headers) - allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL @@ -91,8 +95,8 @@ class TestCors: } req = requests.get(_url(const.URL_API), headers=headers) - allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS + allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN + allow_headers = ACCESS_CONTROL_ALLOW_HEADERS assert req.status_code == 200 assert allow_origin not in req.headers @@ -101,14 +105,14 @@ class TestCors: def test_cors_preflight_allowed(self): """Test cross origin resource sharing preflight (OPTIONS) request.""" headers = { - const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL, - 'Access-Control-Request-Method': 'GET', - 'Access-Control-Request-Headers': 'x-ha-access' + ORIGIN: HTTP_BASE_URL, + ACCESS_CONTROL_REQUEST_METHOD: 'GET', + ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access' } req = requests.options(_url(const.URL_API), headers=headers) - allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS + allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN + allow_headers = ACCESS_CONTROL_ALLOW_HEADERS assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL @@ -158,7 +162,7 @@ def test_registering_view_while_running(hass, test_client): @asyncio.coroutine def test_api_base_url_with_domain(hass): - """Test setting api url.""" + """Test setting API URL.""" result = yield from setup.async_setup_component(hass, 'http', { 'http': { 'base_url': 'example.com' diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 5aa8afb4f7d..2c39cc5dbd7 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -2,6 +2,7 @@ import asyncio import json from unittest.mock import patch, MagicMock, mock_open +from aiohttp.hdrs import AUTHORIZATION from homeassistant.components.notify import html5 @@ -278,8 +279,8 @@ class TestHtml5Notify(object): assert json.loads(handle.write.call_args[0][0]) == config @asyncio.coroutine - def test_unregister_device_view_handle_unknown_subscription(self, loop, - test_client): + def test_unregister_device_view_handle_unknown_subscription( + self, loop, test_client): """Test that the HTML unregister view handles unknown subscriptions.""" hass = MagicMock() @@ -322,8 +323,8 @@ class TestHtml5Notify(object): assert handle.write.call_count == 0 @asyncio.coroutine - def test_unregistering_device_view_handles_json_safe_error(self, loop, - test_client): + def test_unregistering_device_view_handles_json_safe_error( + self, loop, test_client): """Test that the HTML unregister view handles JSON write errors.""" hass = MagicMock() @@ -423,8 +424,8 @@ class TestHtml5Notify(object): assert len(hass.mock_calls) == 3 with patch('pywebpush.WebPusher') as mock_wp: - service.send_message('Hello', target=['device'], - data={'icon': 'beer.png'}) + service.send_message( + 'Hello', target=['device'], data={'icon': 'beer.png'}) assert len(mock_wp.mock_calls) == 3 @@ -453,7 +454,7 @@ class TestHtml5Notify(object): resp = yield from client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', - }), headers={'Authorization': bearer_token}) + }), headers={AUTHORIZATION: bearer_token}) assert resp.status == 200 body = yield from resp.json() diff --git a/tests/components/test_dialogflow.py b/tests/components/test_dialogflow.py index 8275534123c..a52c841e0cc 100644 --- a/tests/components/test_dialogflow.py +++ b/tests/components/test_dialogflow.py @@ -4,6 +4,7 @@ import json import unittest import requests +from aiohttp.hdrs import CONTENT_TYPE from homeassistant.core import callback from homeassistant import setup, const @@ -18,7 +19,7 @@ INTENTS_API_URL = "{}{}".format(BASE_API_URL, dialogflow.INTENTS_API_ENDPOINT) HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, + CONTENT_TYPE: const.CONTENT_TYPE_JSON, } SESSION_ID = "a9b84cec-46b6-484e-8f31-f65dba03ae6d" From 28ef56497416cfebbc0fa564206e6e388468e929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Sun, 5 Nov 2017 13:50:46 +0100 Subject: [PATCH 024/137] fix a import in test causing vs code to fail to discover (#10358) * fix a import in test causing vs code to fail to discover * Change style --- tests/components/media_player/test_monoprice.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py index 439b272fd4a..2bcd02e69aa 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -9,7 +9,8 @@ from homeassistant.components.media_player import ( SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE) from homeassistant.const import STATE_ON, STATE_OFF -from components.media_player.monoprice import MonopriceZone, PLATFORM_SCHEMA +from homeassistant.components.media_player.monoprice import ( + MonopriceZone, PLATFORM_SCHEMA) class MockState(object): From 5be6f8ff36de442220e7707459b1ae5fbc42991f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 5 Nov 2017 13:51:03 +0100 Subject: [PATCH 025/137] Upgrade sqlalchemy to 1.1.15 (#10330) --- 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 e9b08941b83..f8ae9e9d0be 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -36,7 +36,7 @@ from . import purge, migration from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.1.14'] +REQUIREMENTS = ['sqlalchemy==1.1.15'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index bf4ee2b673e..3ed1f77bc6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1028,7 +1028,7 @@ speedtest-cli==1.0.7 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.14 +sqlalchemy==1.1.15 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62aaf4c3b5f..e24db456565 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -156,7 +156,7 @@ somecomfort==0.4.1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.14 +sqlalchemy==1.1.15 # homeassistant.components.statsd statsd==3.2.1 From a5d5f3f7276915469b1d747ce63d1c65a3e02903 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 5 Nov 2017 13:51:52 +0100 Subject: [PATCH 026/137] Move counter component (#10332) * Fix docstring * Add comment * Move counter to folder * Fix missing parts * Commit it when file is saved --- .../{counter.py => counter/__init__.py} | 6 +- .../components/counter/services.yaml | 20 +++++ homeassistant/components/services.yaml | 20 ----- homeassistant/components/timer/services.yaml | 2 + tests/components/counter/__init__.py | 1 + .../{test_counter.py => counter/test_init.py} | 82 +++++++++---------- tests/components/fan/__init__.py | 2 +- 7 files changed, 68 insertions(+), 65 deletions(-) rename homeassistant/components/{counter.py => counter/__init__.py} (96%) create mode 100644 homeassistant/components/counter/services.yaml create mode 100644 tests/components/counter/__init__.py rename tests/components/{test_counter.py => counter/test_init.py} (100%) diff --git a/homeassistant/components/counter.py b/homeassistant/components/counter/__init__.py similarity index 96% rename from homeassistant/components/counter.py rename to homeassistant/components/counter/__init__.py index 64421306644..aee94c069f6 100644 --- a/homeassistant/components/counter.py +++ b/homeassistant/components/counter/__init__.py @@ -140,13 +140,13 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_INCREMENT, async_handler_service, - descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + descriptions[SERVICE_INCREMENT], SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_DECREMENT, async_handler_service, - descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + descriptions[SERVICE_DECREMENT], SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_RESET, async_handler_service, - descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + descriptions[SERVICE_RESET], SERVICE_SCHEMA) yield from component.async_add_entities(entities) return True diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml new file mode 100644 index 00000000000..ef76f9b9eac --- /dev/null +++ b/homeassistant/components/counter/services.yaml @@ -0,0 +1,20 @@ +# Describes the format for available counter services + +decrement: + description: Decrement a counter. + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' +increment: + description: Increment a counter. + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' +reset: + description: Reset a counter. + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' \ No newline at end of file diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 37829142e0c..c4e460fdb66 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -390,26 +390,6 @@ rflink: description: The command to be sent. example: 'on' -counter: - decrement: - description: Decrement a counter. - fields: - entity_id: - description: Entity id of the counter to decrement. - example: 'counter.count0' - increment: - description: Increment a counter. - fields: - entity_id: - description: Entity id of the counter to increment. - example: 'counter.count0' - reset: - description: Reset a counter. - fields: - entity_id: - description: Entity id of the counter to reset. - example: 'counter.count0' - abode: change_setting: description: Change an Abode system setting. diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index f7d2c1a77b5..b299aaa8185 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -1,3 +1,5 @@ +# Describes the format for available timer services + start: description: Start a timer. diff --git a/tests/components/counter/__init__.py b/tests/components/counter/__init__.py new file mode 100644 index 00000000000..7ebe8e7d7b5 --- /dev/null +++ b/tests/components/counter/__init__.py @@ -0,0 +1 @@ +"""Tests for the counter component.""" diff --git a/tests/components/test_counter.py b/tests/components/counter/test_init.py similarity index 100% rename from tests/components/test_counter.py rename to tests/components/counter/test_init.py index 8dc04f0e76a..f4c6ee9c7da 100644 --- a/tests/components/test_counter.py +++ b/tests/components/counter/test_init.py @@ -42,6 +42,47 @@ class TestCounter(unittest.TestCase): self.assertFalse( setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + def test_config_options(self): + """Test configuration options.""" + count_start = len(self.hass.states.entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + self.hass.block_till_done() + + _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) + + self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) + self.hass.block_till_done() + + state_1 = self.hass.states.get('counter.test_1') + state_2 = self.hass.states.get('counter.test_2') + + self.assertIsNotNone(state_1) + self.assertIsNotNone(state_2) + + self.assertEqual(0, int(state_1.state)) + self.assertNotIn(ATTR_ICON, state_1.attributes) + self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) + + self.assertEqual(10, int(state_2.state)) + self.assertEqual('Hello World', + state_2.attributes.get(ATTR_FRIENDLY_NAME)) + self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) + def test_methods(self): """Test increment, decrement, and reset methods.""" config = { @@ -118,47 +159,6 @@ class TestCounter(unittest.TestCase): state = self.hass.states.get(entity_id) self.assertEqual(15, int(state.state)) - def test_config_options(self): - """Test configuration options.""" - count_start = len(self.hass.states.entity_ids()) - - _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) - - config = { - DOMAIN: { - 'test_1': {}, - 'test_2': { - CONF_NAME: 'Hello World', - CONF_ICON: 'mdi:work', - CONF_INITIAL: 10, - CONF_STEP: 5, - } - } - } - - assert setup_component(self.hass, 'counter', config) - self.hass.block_till_done() - - _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) - - self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) - self.hass.block_till_done() - - state_1 = self.hass.states.get('counter.test_1') - state_2 = self.hass.states.get('counter.test_2') - - self.assertIsNotNone(state_1) - self.assertIsNotNone(state_2) - - self.assertEqual(0, int(state_1.state)) - self.assertNotIn(ATTR_ICON, state_1.attributes) - self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) - - self.assertEqual(10, int(state_2.state)) - self.assertEqual('Hello World', - state_2.attributes.get(ATTR_FRIENDLY_NAME)) - self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) - @asyncio.coroutine def test_initial_state_overrules_restore_state(hass): diff --git a/tests/components/fan/__init__.py b/tests/components/fan/__init__.py index 54ed1fcc505..28ae7f4e249 100644 --- a/tests/components/fan/__init__.py +++ b/tests/components/fan/__init__.py @@ -1,4 +1,4 @@ -"""Test fan component plaforms.""" +"""Tests for fan platforms.""" import unittest From 72ce9ec32118795baf2c7beb171d5122236a42e9 Mon Sep 17 00:00:00 2001 From: Adam Cooper Date: Sun, 5 Nov 2017 13:10:14 +0000 Subject: [PATCH 027/137] Add platform and sensors for Vultr VPS (#9928) * Initial commit of Vultr components Have a working Vultr hub and binary sensor which pulls down the following attributes of your VPS: - Date created - Subscription id (server id) - Cost per month (in US$) - Operating System installed - IPv4 address - label (human readable name) - region - number of vcpus - which storage package chosen - IPV6 address (if applicable) - RAM amount Working next on sensor and then testing / coverage. * Added Vultr sensor for pending charges and current bandwidth. Refactored binary_sensor and hub too * Corrected is_on bases * Added basic tests for Vultr binary & platform * Updated require files * Changing test fixture to highlight different cases * Written basic test for sensor.vultr * Resolved linting errors and broken test * Increase test coverage and corrected docs * Resolved hound issues * Revert back negative binary test * Another hound resolve * Refactoring and adding is switch, moving over to vultr branch * Made Vultr components more resiliant to invalid configs * Added negetive test for vultr binary sensor * Added better testing of vultr sensor * Resolved vultr platform test affecting subsequent vultr tests * Moving VULTR components to single use design * Added in sensor name config * Added missing sensors var * Resolved init data setting of sensors, added in name conf to switch * Made the Vultr component more resiliant to startup failure with better alerting * Various Vultr component changes - Refactored sensor, binary_sensor, and switch to reference one subscription - Renamed CURRENT_BANDWIDTH_GB monitored condition to CURRENT_BANDWIDTH_USED - Improved test coverage * Resolved local tox linting issue * Added more testing for Vultr switch * Improved test coverage for Vultr components * Made PR comment changes to vultr binary sensor * Made PR comment changes to Vultr sensor * resolved PR comments for Vultr Switch * Resolved vultr sensor name and improved tests * Improved Vultr switch testing (default name formatting) * Removed vultr hub failure checking --- .../components/binary_sensor/vultr.py | 103 +++++++++ homeassistant/components/sensor/vultr.py | 115 ++++++++++ homeassistant/components/switch/vultr.py | 106 +++++++++ homeassistant/components/vultr.py | 105 +++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 3 +- tests/components/binary_sensor/test_vultr.py | 165 ++++++++++++++ tests/components/sensor/test_vultr.py | 165 ++++++++++++++ tests/components/switch/test_vultr.py | 201 ++++++++++++++++++ tests/components/test_vultr.py | 48 +++++ tests/fixtures/vultr_account_info.json | 1 + tests/fixtures/vultr_server_list.json | 122 +++++++++++ 13 files changed, 1139 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/binary_sensor/vultr.py create mode 100644 homeassistant/components/sensor/vultr.py create mode 100644 homeassistant/components/switch/vultr.py create mode 100644 homeassistant/components/vultr.py create mode 100644 tests/components/binary_sensor/test_vultr.py create mode 100644 tests/components/sensor/test_vultr.py create mode 100644 tests/components/switch/test_vultr.py create mode 100644 tests/components/test_vultr.py create mode 100644 tests/fixtures/vultr_account_info.json create mode 100644 tests/fixtures/vultr_server_list.json diff --git a/homeassistant/components/binary_sensor/vultr.py b/homeassistant/components/binary_sensor/vultr.py new file mode 100644 index 00000000000..66b5a127be1 --- /dev/null +++ b/homeassistant/components/binary_sensor/vultr.py @@ -0,0 +1,103 @@ +""" +Support for monitoring the state of Vultr subscriptions (VPS). + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.vultr/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.vultr import ( + CONF_SUBSCRIPTION, ATTR_AUTO_BACKUPS, ATTR_ALLOWED_BANDWIDTH, + ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, ATTR_SUBSCRIPTION_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, ATTR_DISK, + ATTR_COST_PER_MONTH, ATTR_OS, ATTR_REGION, ATTR_VCPUS, DATA_VULTR) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_CLASS = 'power' +DEFAULT_NAME = 'Vultr {}' +DEPENDENCIES = ['vultr'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SUBSCRIPTION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vultr subscription (server) sensor.""" + vultr = hass.data[DATA_VULTR] + + subscription = config.get(CONF_SUBSCRIPTION) + name = config.get(CONF_NAME) + + if subscription not in vultr.data: + _LOGGER.error("Subscription %s not found", subscription) + return False + + add_devices([VultrBinarySensor(vultr, subscription, name)], True) + + +class VultrBinarySensor(BinarySensorDevice): + """Representation of a Vultr subscription sensor.""" + + def __init__(self, vultr, subscription, name): + """Initialize a new Vultr sensor.""" + self._vultr = vultr + self._name = name + + self.subscription = subscription + self.data = None + + @property + def name(self): + """Return the name of the sensor.""" + try: + return self._name.format(self.data['label']) + except (KeyError, TypeError): + return self._name + + @property + def icon(self): + """Return the icon of this server.""" + return 'mdi:server' if self.is_on else 'mdi:server-off' + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.data['power_status'] == 'running' + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes of the Vultr subscription.""" + return { + ATTR_ALLOWED_BANDWIDTH: self.data.get('allowed_bandwidth_gb'), + ATTR_AUTO_BACKUPS: self.data.get('auto_backups'), + ATTR_COST_PER_MONTH: self.data.get('cost_per_month'), + ATTR_CREATED_AT: self.data.get('date_created'), + ATTR_DISK: self.data.get('disk'), + ATTR_IPV4_ADDRESS: self.data.get('main_ip'), + ATTR_IPV6_ADDRESS: self.data.get('v6_main_ip'), + ATTR_MEMORY: self.data.get('ram'), + ATTR_OS: self.data.get('os'), + ATTR_REGION: self.data.get('location'), + ATTR_SUBSCRIPTION_ID: self.data.get('SUBID'), + ATTR_SUBSCRIPTION_NAME: self.data.get('label'), + ATTR_VCPUS: self.data.get('vcpu_count') + } + + def update(self): + """Update state of sensor.""" + self._vultr.update() + self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/sensor/vultr.py b/homeassistant/components/sensor/vultr.py new file mode 100644 index 00000000000..7a3db3895dc --- /dev/null +++ b/homeassistant/components/sensor/vultr.py @@ -0,0 +1,115 @@ +""" +Support for monitoring the state of Vultr Subscriptions. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.vultr/ +""" +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, CONF_NAME) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.vultr import ( + CONF_SUBSCRIPTION, ATTR_CURRENT_BANDWIDTH_USED, ATTR_PENDING_CHARGES, + DATA_VULTR) + +# Name defaults to {subscription label} {sensor name} +DEFAULT_NAME = 'Vultr {} {}' +DEPENDENCIES = ['vultr'] + +_LOGGER = logging.getLogger(__name__) + +# Monitored conditions: name, units, icon +MONITORED_CONDITIONS = { + ATTR_CURRENT_BANDWIDTH_USED: ['Current Bandwidth Used', 'GB', + 'mdi:chart-histogram'], + ATTR_PENDING_CHARGES: ['Pending Charges', 'US$', + 'mdi:currency-usd'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SUBSCRIPTION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vultr subscription (server) sensor.""" + vultr = hass.data[DATA_VULTR] + + subscription = config.get(CONF_SUBSCRIPTION) + name = config.get(CONF_NAME) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + + if subscription not in vultr.data: + _LOGGER.error("Subscription %s not found", subscription) + return False + + sensors = [] + + for condition in monitored_conditions: + sensors.append(VultrSensor(vultr, + subscription, + condition, + name)) + + add_devices(sensors, True) + + +class VultrSensor(Entity): + """Representation of a Vultr subscription sensor.""" + + def __init__(self, vultr, subscription, condition, name): + """Initialize a new Vultr sensor.""" + self._vultr = vultr + self._condition = condition + self._name = name + + self.subscription = subscription + self.data = None + + condition_info = MONITORED_CONDITIONS[condition] + + self._condition_name = condition_info[0] + self._units = condition_info[1] + self._icon = condition_info[2] + + @property + def name(self): + """Return the name of the sensor.""" + try: + return self._name.format(self._condition_name) + except IndexError: # name contains more {} than fulfilled + try: + return self._name.format(self.data['label'], + self._condition_name) + except (KeyError, TypeError): # label key missing or data is None + return self._name + + @property + def icon(self): + """Icon used in the frontend if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """The unit of measurement to present the value in.""" + return self._units + + @property + def state(self): + """Return the value of this given sensor type.""" + try: + return round(float(self.data.get(self._condition)), 2) + except (TypeError, ValueError): + return self.data.get(self._condition) + + def update(self): + """Update state of sensor.""" + self._vultr.update() + self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/switch/vultr.py b/homeassistant/components/switch/vultr.py new file mode 100644 index 00000000000..888db754f01 --- /dev/null +++ b/homeassistant/components/switch/vultr.py @@ -0,0 +1,106 @@ +""" +Support for interacting with Vultr subscriptions. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.vultr/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.vultr import ( + CONF_SUBSCRIPTION, ATTR_AUTO_BACKUPS, ATTR_ALLOWED_BANDWIDTH, + ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, ATTR_SUBSCRIPTION_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, ATTR_DISK, + ATTR_COST_PER_MONTH, ATTR_OS, ATTR_REGION, ATTR_VCPUS, DATA_VULTR) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Vultr {}' +DEPENDENCIES = ['vultr'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SUBSCRIPTION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vultr subscription switch.""" + vultr = hass.data[DATA_VULTR] + + subscription = config.get(CONF_SUBSCRIPTION) + name = config.get(CONF_NAME) + + if subscription not in vultr.data: + _LOGGER.error("Subscription %s not found", subscription) + return False + + add_devices([VultrSwitch(vultr, subscription, name)], True) + + +class VultrSwitch(SwitchDevice): + """Representation of a Vultr subscription switch.""" + + def __init__(self, vultr, subscription, name): + """Initialize a new Vultr switch.""" + self._vultr = vultr + self._name = name + + self.subscription = subscription + self.data = None + + @property + def name(self): + """Return the name of the switch.""" + try: + return self._name.format(self.data['label']) + except (TypeError, KeyError): + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self.data['power_status'] == 'running' + + @property + def icon(self): + """Return the icon of this server.""" + return 'mdi:server' if self.is_on else 'mdi:server-off' + + @property + def device_state_attributes(self): + """Return the state attributes of the Vultr subscription.""" + return { + ATTR_ALLOWED_BANDWIDTH: self.data.get('allowed_bandwidth_gb'), + ATTR_AUTO_BACKUPS: self.data.get('auto_backups'), + ATTR_COST_PER_MONTH: self.data.get('cost_per_month'), + ATTR_CREATED_AT: self.data.get('date_created'), + ATTR_DISK: self.data.get('disk'), + ATTR_IPV4_ADDRESS: self.data.get('main_ip'), + ATTR_IPV6_ADDRESS: self.data.get('v6_main_ip'), + ATTR_MEMORY: self.data.get('ram'), + ATTR_OS: self.data.get('os'), + ATTR_REGION: self.data.get('location'), + ATTR_SUBSCRIPTION_ID: self.data.get('SUBID'), + ATTR_SUBSCRIPTION_NAME: self.data.get('label'), + ATTR_VCPUS: self.data.get('vcpu_count'), + } + + def turn_on(self): + """Boot-up the subscription.""" + if self.data['power_status'] != 'running': + self._vultr.start(self.subscription) + + def turn_off(self): + """Halt the subscription.""" + if self.data['power_status'] == 'running': + self._vultr.halt(self.subscription) + + def update(self): + """Get the latest data from the device and update the data.""" + self._vultr.update() + self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/vultr.py b/homeassistant/components/vultr.py new file mode 100644 index 00000000000..59fc707bb28 --- /dev/null +++ b/homeassistant/components/vultr.py @@ -0,0 +1,105 @@ +""" +Support for Vultr. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/vultr/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['vultr==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_AUTO_BACKUPS = 'auto_backups' +ATTR_ALLOWED_BANDWIDTH = 'allowed_bandwidth_gb' +ATTR_COST_PER_MONTH = 'cost_per_month' +ATTR_CURRENT_BANDWIDTH_USED = 'current_bandwidth_gb' +ATTR_CREATED_AT = 'created_at' +ATTR_DISK = 'disk' +ATTR_SUBSCRIPTION_ID = 'subid' +ATTR_SUBSCRIPTION_NAME = 'label' +ATTR_IPV4_ADDRESS = 'ipv4_address' +ATTR_IPV6_ADDRESS = 'ipv6_address' +ATTR_MEMORY = 'memory' +ATTR_OS = 'os' +ATTR_PENDING_CHARGES = 'pending_charges' +ATTR_REGION = 'region' +ATTR_VCPUS = 'vcpus' + +CONF_SUBSCRIPTION = 'subscription' + +DATA_VULTR = 'data_vultr' +DOMAIN = 'vultr' + +NOTIFICATION_ID = 'vultr_notification' +NOTIFICATION_TITLE = 'Vultr Setup' + +VULTR_PLATFORMS = ['binary_sensor', 'sensor', 'switch'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Vultr component.""" + api_key = config[DOMAIN].get(CONF_API_KEY) + + vultr = Vultr(api_key) + + try: + vultr.update() + except RuntimeError as ex: + _LOGGER.error("Failed to make update API request because: %s", + ex) + hass.components.persistent_notification.create( + 'Error: {}' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + hass.data[DATA_VULTR] = vultr + return True + + +class Vultr(object): + """Handle all communication with the Vultr API.""" + + def __init__(self, api_key): + """Initialize the Vultr connection.""" + from vultr import Vultr as VultrAPI + + self._api_key = api_key + self.data = None + self.api = VultrAPI(self._api_key) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Use the data from Vultr API.""" + self.data = self.api.server_list() + + def _force_update(self): + """Use the data from Vultr API.""" + self.data = self.api.server_list() + + def halt(self, subscription): + """Halt a subscription (hard power off).""" + self.api.server_halt(subscription) + self._force_update() + + def start(self, subscription): + """Start a subscription.""" + self.api.server_start(subscription) + self._force_update() diff --git a/requirements_all.txt b/requirements_all.txt index 3ed1f77bc6d..d259fdd5014 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1101,6 +1101,9 @@ vsure==1.3.7 # homeassistant.components.sensor.vasttrafik vtjp==0.1.14 +# homeassistant.components.vultr +vultr==0.1.2 + # homeassistant.components.wake_on_lan # homeassistant.components.media_player.panasonic_viera # homeassistant.components.media_player.samsungtv diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e24db456565..3fee56ae031 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,6 +164,9 @@ statsd==3.2.1 # homeassistant.components.camera.uvc uvcclient==0.10.1 +# homeassistant.components.vultr +vultr==0.1.2 + # homeassistant.components.wake_on_lan # homeassistant.components.media_player.panasonic_viera # homeassistant.components.media_player.samsungtv diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d2ac40c2550..9d9725e9e6a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -82,7 +82,8 @@ TEST_REQUIREMENTS = ( 'warrant', 'yahoo-finance', 'pythonwhois', - 'wakeonlan' + 'wakeonlan', + 'vultr' ) IGNORE_PACKAGES = ( diff --git a/tests/components/binary_sensor/test_vultr.py b/tests/components/binary_sensor/test_vultr.py new file mode 100644 index 00000000000..7b0cc8caa87 --- /dev/null +++ b/tests/components/binary_sensor/test_vultr.py @@ -0,0 +1,165 @@ +"""Test the Vultr binary sensor platform.""" +import unittest +import requests_mock +import pytest +import voluptuous as vol + +from components.binary_sensor import vultr +from components import vultr as base_vultr +from components.vultr import ( + ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_IPV4_ADDRESS, + ATTR_COST_PER_MONTH, ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, + CONF_SUBSCRIPTION) +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) + +from tests.components.test_vultr import VALID_CONFIG +from tests.common import ( + get_test_home_assistant, load_fixture) + + +class TestVultrBinarySensorSetup(unittest.TestCase): + """Test the Vultr binary sensor platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Init values for this testcase class.""" + self.hass = get_test_home_assistant() + self.configs = [ + { + CONF_SUBSCRIPTION: '576965', + CONF_NAME: "A Server" + }, + { + CONF_SUBSCRIPTION: '123456', + CONF_NAME: "Failed Server" + }, + { + CONF_SUBSCRIPTION: '555555', + CONF_NAME: vultr.DEFAULT_NAME + } + ] + + def tearDown(self): + """Stop our started services.""" + self.hass.stop() + + def test_failed_hub(self): + """Test a hub setup failure.""" + base_vultr.setup(self.hass, VALID_CONFIG) + + @requests_mock.Mocker() + def test_binary_sensor(self, mock): + """Test successful instance.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) + + # Setup each of our test configs + for config in self.configs: + vultr.setup_platform(self.hass, + config, + self.add_devices, + None) + + self.assertEqual(len(self.DEVICES), 3) + + for device in self.DEVICES: + + # Test pre data retieval + if device.subscription == '555555': + self.assertEqual('Vultr {}', device.name) + + device.update() + device_attrs = device.device_state_attributes + + if device.subscription == '555555': + self.assertEqual('Vultr Another Server', device.name) + + if device.name == 'A Server': + self.assertEqual(True, device.is_on) + self.assertEqual('power', device.device_class) + self.assertEqual('on', device.state) + self.assertEqual('mdi:server', device.icon) + self.assertEqual('1000', + device_attrs[ATTR_ALLOWED_BANDWIDTH]) + self.assertEqual('yes', + device_attrs[ATTR_AUTO_BACKUPS]) + self.assertEqual('123.123.123.123', + device_attrs[ATTR_IPV4_ADDRESS]) + self.assertEqual('10.05', + device_attrs[ATTR_COST_PER_MONTH]) + self.assertEqual('2013-12-19 14:45:41', + device_attrs[ATTR_CREATED_AT]) + self.assertEqual('576965', + device_attrs[ATTR_SUBSCRIPTION_ID]) + elif device.name == 'Failed Server': + self.assertEqual(False, device.is_on) + self.assertEqual('off', device.state) + self.assertEqual('mdi:server-off', device.icon) + self.assertEqual('1000', + device_attrs[ATTR_ALLOWED_BANDWIDTH]) + self.assertEqual('no', + device_attrs[ATTR_AUTO_BACKUPS]) + self.assertEqual('192.168.100.50', + device_attrs[ATTR_IPV4_ADDRESS]) + self.assertEqual('73.25', + device_attrs[ATTR_COST_PER_MONTH]) + self.assertEqual('2014-10-13 14:45:41', + device_attrs[ATTR_CREATED_AT]) + self.assertEqual('123456', + device_attrs[ATTR_SUBSCRIPTION_ID]) + + def test_invalid_sensor_config(self): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subs + vultr.PLATFORM_SCHEMA({ + CONF_PLATFORM: base_vultr.DOMAIN, + }) + + @requests_mock.Mocker() + def test_invalid_sensors(self, mock): + """Test the VultrBinarySensor fails.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + base_vultr.setup(self.hass, VALID_CONFIG) + + bad_conf = {} # No subscription + + no_subs_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertFalse(no_subs_setup) + + bad_conf = { + CONF_NAME: "Missing Server", + CONF_SUBSCRIPTION: '555555' + } # Sub not associated with API key (not in server_list) + + wrong_subs_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertFalse(wrong_subs_setup) diff --git a/tests/components/sensor/test_vultr.py b/tests/components/sensor/test_vultr.py new file mode 100644 index 00000000000..ba5730f4acf --- /dev/null +++ b/tests/components/sensor/test_vultr.py @@ -0,0 +1,165 @@ +"""The tests for the Vultr sensor platform.""" +import pytest +import unittest +import requests_mock +import voluptuous as vol + +from components.sensor import vultr +from components import vultr as base_vultr +from components.vultr import CONF_SUBSCRIPTION +from homeassistant.const import ( + CONF_NAME, CONF_MONITORED_CONDITIONS, CONF_PLATFORM) + +from tests.components.test_vultr import VALID_CONFIG +from tests.common import ( + get_test_home_assistant, load_fixture) + + +class TestVultrSensorSetup(unittest.TestCase): + """Test the Vultr platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.configs = [ + { + CONF_NAME: vultr.DEFAULT_NAME, + CONF_SUBSCRIPTION: '576965', + CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS + }, + { + CONF_NAME: 'Server {}', + CONF_SUBSCRIPTION: '123456', + CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS + }, + { + CONF_NAME: 'VPS Charges', + CONF_SUBSCRIPTION: '555555', + CONF_MONITORED_CONDITIONS: [ + 'pending_charges' + ] + } + ] + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_sensor(self, mock): + """Test the Vultr sensor class and methods.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + base_vultr.setup(self.hass, VALID_CONFIG) + + for config in self.configs: + setup = vultr.setup_platform(self.hass, + config, + self.add_devices, + None) + + self.assertIsNone(setup) + + self.assertEqual(5, len(self.DEVICES)) + + tested = 0 + + for device in self.DEVICES: + + # Test pre update + if device.subscription == '576965': + self.assertEqual(vultr.DEFAULT_NAME, device.name) + + device.update() + + if device.unit_of_measurement == 'GB': # Test Bandwidth Used + if device.subscription == '576965': + self.assertEqual( + 'Vultr my new server Current Bandwidth Used', + device.name) + self.assertEqual('mdi:chart-histogram', device.icon) + self.assertEqual(131.51, device.state) + self.assertEqual('mdi:chart-histogram', device.icon) + tested += 1 + + elif device.subscription == '123456': + self.assertEqual('Server Current Bandwidth Used', + device.name) + self.assertEqual(957.46, device.state) + tested += 1 + + elif device.unit_of_measurement == 'US$': # Test Pending Charges + + if device.subscription == '576965': # Default 'Vultr {} {}' + self.assertEqual('Vultr my new server Pending Charges', + device.name) + self.assertEqual('mdi:currency-usd', device.icon) + self.assertEqual(46.67, device.state) + self.assertEqual('mdi:currency-usd', device.icon) + tested += 1 + + elif device.subscription == '123456': # Custom name with 1 {} + self.assertEqual('Server Pending Charges', device.name) + self.assertEqual('not a number', device.state) + tested += 1 + + elif device.subscription == '555555': # No {} in name + self.assertEqual('VPS Charges', device.name) + self.assertEqual(5.45, device.state) + tested += 1 + + self.assertEqual(tested, 5) + + def test_invalid_sensor_config(self): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subscription + vultr.PLATFORM_SCHEMA({ + CONF_PLATFORM: base_vultr.DOMAIN, + CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS + }) + with pytest.raises(vol.Invalid): # Bad monitored_conditions + vultr.PLATFORM_SCHEMA({ + CONF_PLATFORM: base_vultr.DOMAIN, + CONF_SUBSCRIPTION: '123456', + CONF_MONITORED_CONDITIONS: [ + 'non-existent-condition', + ] + }) + + @requests_mock.Mocker() + def test_invalid_sensors(self, mock): + """Test the VultrSensor fails.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + base_vultr.setup(self.hass, VALID_CONFIG) + + bad_conf = { + CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, + } # No subs at all + + no_sub_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertIsNotNone(no_sub_setup) + self.assertEqual(0, len(self.DEVICES)) diff --git a/tests/components/switch/test_vultr.py b/tests/components/switch/test_vultr.py new file mode 100644 index 00000000000..e5eb8800f98 --- /dev/null +++ b/tests/components/switch/test_vultr.py @@ -0,0 +1,201 @@ +"""Test the Vultr switch platform.""" +import unittest +import requests_mock +import pytest +import voluptuous as vol + +from components.switch import vultr +from components import vultr as base_vultr +from components.vultr import ( + ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_IPV4_ADDRESS, + ATTR_COST_PER_MONTH, ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, + CONF_SUBSCRIPTION) +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) + +from tests.components.test_vultr import VALID_CONFIG +from tests.common import ( + get_test_home_assistant, load_fixture) + + +class TestVultrSwitchSetup(unittest.TestCase): + """Test the Vultr switch platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Init values for this testcase class.""" + self.hass = get_test_home_assistant() + self.configs = [ + { + CONF_SUBSCRIPTION: '576965', + CONF_NAME: "A Server" + }, + { + CONF_SUBSCRIPTION: '123456', + CONF_NAME: "Failed Server" + }, + { + CONF_SUBSCRIPTION: '555555', + CONF_NAME: vultr.DEFAULT_NAME + } + ] + + def tearDown(self): + """Stop our started services.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_switch(self, mock): + """Test successful instance.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) + + # Setup each of our test configs + for config in self.configs: + vultr.setup_platform(self.hass, + config, + self.add_devices, + None) + + self.assertEqual(len(self.DEVICES), 3) + + tested = 0 + + for device in self.DEVICES: + if device.subscription == '555555': + self.assertEqual('Vultr {}', device.name) + tested += 1 + + device.update() + device_attrs = device.device_state_attributes + + if device.subscription == '555555': + self.assertEqual('Vultr Another Server', device.name) + tested += 1 + + if device.name == 'A Server': + self.assertEqual(True, device.is_on) + self.assertEqual('on', device.state) + self.assertEqual('mdi:server', device.icon) + self.assertEqual('1000', + device_attrs[ATTR_ALLOWED_BANDWIDTH]) + self.assertEqual('yes', + device_attrs[ATTR_AUTO_BACKUPS]) + self.assertEqual('123.123.123.123', + device_attrs[ATTR_IPV4_ADDRESS]) + self.assertEqual('10.05', + device_attrs[ATTR_COST_PER_MONTH]) + self.assertEqual('2013-12-19 14:45:41', + device_attrs[ATTR_CREATED_AT]) + self.assertEqual('576965', + device_attrs[ATTR_SUBSCRIPTION_ID]) + tested += 1 + + elif device.name == 'Failed Server': + self.assertEqual(False, device.is_on) + self.assertEqual('off', device.state) + self.assertEqual('mdi:server-off', device.icon) + self.assertEqual('1000', + device_attrs[ATTR_ALLOWED_BANDWIDTH]) + self.assertEqual('no', + device_attrs[ATTR_AUTO_BACKUPS]) + self.assertEqual('192.168.100.50', + device_attrs[ATTR_IPV4_ADDRESS]) + self.assertEqual('73.25', + device_attrs[ATTR_COST_PER_MONTH]) + self.assertEqual('2014-10-13 14:45:41', + device_attrs[ATTR_CREATED_AT]) + self.assertEqual('123456', + device_attrs[ATTR_SUBSCRIPTION_ID]) + tested += 1 + + self.assertEqual(4, tested) + + @requests_mock.Mocker() + def test_turn_on(self, mock): + """Test turning a subscription on.""" + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + mock.post( + 'https://api.vultr.com/v1/server/start?api_key=ABCDEFG1234567') + + for device in self.DEVICES: + if device.name == 'Failed Server': + device.turn_on() + + # Turn on, force date update + self.assertEqual(2, mock.call_count) + + @requests_mock.Mocker() + def test_turn_off(self, mock): + """Test turning a subscription off.""" + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + mock.post( + 'https://api.vultr.com/v1/server/halt?api_key=ABCDEFG1234567') + + for device in self.DEVICES: + if device.name == 'A Server': + device.turn_off() + + # Turn off, force update + self.assertEqual(2, mock.call_count) + + def test_invalid_switch_config(self): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subscription + vultr.PLATFORM_SCHEMA({ + CONF_PLATFORM: base_vultr.DOMAIN, + }) + + @requests_mock.Mocker() + def test_invalid_switches(self, mock): + """Test the VultrSwitch fails.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + base_vultr.setup(self.hass, VALID_CONFIG) + + bad_conf = {} # No subscription + + no_subs_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertIsNotNone(no_subs_setup) + + bad_conf = { + CONF_NAME: "Missing Server", + CONF_SUBSCRIPTION: '665544' + } # Sub not associated with API key (not in server_list) + + wrong_subs_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertIsNotNone(wrong_subs_setup) diff --git a/tests/components/test_vultr.py b/tests/components/test_vultr.py new file mode 100644 index 00000000000..ddddcd2be6c --- /dev/null +++ b/tests/components/test_vultr.py @@ -0,0 +1,48 @@ +"""The tests for the Vultr component.""" +import unittest +import requests_mock + +from copy import deepcopy +from homeassistant import setup +import components.vultr as vultr + +from tests.common import ( + get_test_home_assistant, load_fixture) + +VALID_CONFIG = { + 'vultr': { + 'api_key': 'ABCDEFG1234567' + } +} + + +class TestVultr(unittest.TestCase): + """Tests the Vultr component.""" + + def setUp(self): + """Initialize values for this test case class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that we started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_setup(self, mock): + """Test successful setup.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + response = vultr.setup(self.hass, self.config) + self.assertTrue(response) + + def test_setup_no_api_key(self): + """Test failed setup with missing API Key.""" + conf = deepcopy(self.config) + del conf['vultr']['api_key'] + assert not setup.setup_component(self.hass, vultr.DOMAIN, conf) diff --git a/tests/fixtures/vultr_account_info.json b/tests/fixtures/vultr_account_info.json new file mode 100644 index 00000000000..beab9534fc3 --- /dev/null +++ b/tests/fixtures/vultr_account_info.json @@ -0,0 +1 @@ +{"balance":"-123.00","pending_charges":"3.38","last_payment_date":"2017-08-11 15:04:04","last_payment_amount":"-10.00"} diff --git a/tests/fixtures/vultr_server_list.json b/tests/fixtures/vultr_server_list.json new file mode 100644 index 00000000000..99955e332ec --- /dev/null +++ b/tests/fixtures/vultr_server_list.json @@ -0,0 +1,122 @@ +{ + "576965": { + "SUBID": "576965", + "os": "CentOS 6 x64", + "ram": "4096 MB", + "disk": "Virtual 60 GB", + "main_ip": "123.123.123.123", + "vcpu_count": "2", + "location": "New Jersey", + "DCID": "1", + "default_password": "nreqnusibni", + "date_created": "2013-12-19 14:45:41", + "pending_charges": "46.67", + "status": "active", + "cost_per_month": "10.05", + "current_bandwidth_gb": 131.512, + "allowed_bandwidth_gb": "1000", + "netmask_v4": "255.255.255.248", + "gateway_v4": "123.123.123.1", + "power_status": "running", + "server_state": "ok", + "VPSPLANID": "28", + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64", + "v6_networks": [ + { + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64" + } + ], + "label": "my new server", + "internal_ip": "10.99.0.10", + "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", + "auto_backups": "yes", + "tag": "mytag", + "OSID": "127", + "APPID": "0", + "FIREWALLGROUPID": "0" + }, + "123456": { + "SUBID": "123456", + "os": "CentOS 6 x64", + "ram": "4096 MB", + "disk": "Virtual 60 GB", + "main_ip": "192.168.100.50", + "vcpu_count": "2", + "location": "New Jersey", + "DCID": "1", + "default_password": "nreqnusibni", + "date_created": "2014-10-13 14:45:41", + "pending_charges": "not a number", + "status": "active", + "cost_per_month": "73.25", + "current_bandwidth_gb": 957.457, + "allowed_bandwidth_gb": "1000", + "netmask_v4": "255.255.255.248", + "gateway_v4": "123.123.123.1", + "power_status": "halted", + "server_state": "ok", + "VPSPLANID": "28", + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64", + "v6_networks": [ + { + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64" + } + ], + "label": "my failed server", + "internal_ip": "10.99.0.10", + "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", + "auto_backups": "no", + "tag": "mytag", + "OSID": "127", + "APPID": "0", + "FIREWALLGROUPID": "0" + }, + "555555": { + "SUBID": "555555", + "os": "CentOS 7 x64", + "ram": "1024 MB", + "disk": "Virtual 30 GB", + "main_ip": "192.168.250.50", + "vcpu_count": "1", + "location": "London", + "DCID": "7", + "default_password": "password", + "date_created": "2014-10-15 14:45:41", + "pending_charges": "5.45", + "status": "active", + "cost_per_month": "73.25", + "current_bandwidth_gb": 57.457, + "allowed_bandwidth_gb": "100", + "netmask_v4": "255.255.255.248", + "gateway_v4": "123.123.123.1", + "power_status": "halted", + "server_state": "ok", + "VPSPLANID": "28", + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64", + "v6_networks": [ + { + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64" + } + ], + "label": "Another Server", + "internal_ip": "10.99.0.10", + "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", + "auto_backups": "no", + "tag": "mytag", + "OSID": "127", + "APPID": "0", + "FIREWALLGROUPID": "0" + } +} From bc51bd93f4ee28ec3e41442eb40b078409714016 Mon Sep 17 00:00:00 2001 From: Patrik Date: Sun, 5 Nov 2017 17:43:45 +0100 Subject: [PATCH 028/137] Fix tradfri problem with brightness (#10359) * Fix problem with brightness * Fix typo * Typo --- homeassistant/components/light/tradfri.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index c71ca60ee03..63441e6d8af 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -108,6 +108,9 @@ class TradfriGroup(Light): keys['transition_time'] = int(kwargs[ATTR_TRANSITION]) * 10 if ATTR_BRIGHTNESS in kwargs: + if kwargs[ATTR_BRIGHTNESS] == 255: + kwargs[ATTR_BRIGHTNESS] = 254 + self.hass.async_add_job(self._api( self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys))) else: @@ -264,6 +267,9 @@ class TradfriLight(Light): keys['transition_time'] = int(kwargs[ATTR_TRANSITION]) * 10 if ATTR_BRIGHTNESS in kwargs: + if kwargs[ATTR_BRIGHTNESS] == 255: + kwargs[ATTR_BRIGHTNESS] = 254 + self.hass.async_add_job(self._api( self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys))) From c07e6510133ddbff8f5ec1b5fee27eecf7f0597f Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Sun, 5 Nov 2017 18:19:19 +0100 Subject: [PATCH 029/137] Add heal_node and test_node services. (#10369) * Add heal_node and test_node services. * lint --- homeassistant/components/zwave/__init__.py | 37 ++++++++++++++++++++ homeassistant/components/zwave/const.py | 4 +++ homeassistant/components/zwave/services.yaml | 21 +++++++++++ tests/components/zwave/test_init.py | 24 +++++++++++++ 4 files changed, 86 insertions(+) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 0e6e41c63a5..2faeccde154 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -123,6 +123,17 @@ SET_WAKEUP_SCHEMA = vol.Schema({ vol.All(vol.Coerce(int), cv.positive_int), }) +HEAL_NODE_SCHEMA = vol.Schema({ + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), + vol.Optional(const.ATTR_RETURN_ROUTES, default=False): cv.boolean, +}) + +TEST_NODE_SCHEMA = vol.Schema({ + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), + vol.Optional(const.ATTR_MESSAGES, default=1): cv.positive_int, +}) + + DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ vol.Optional(CONF_POLLING_INTENSITY): cv.positive_int, vol.Optional(CONF_IGNORED, default=DEFAULT_CONF_IGNORED): cv.boolean, @@ -564,6 +575,22 @@ def setup(hass, config): _LOGGER.info("Node %s on instance %s does not have resettable " "meters.", node_id, instance) + def heal_node(service): + """Heal a node on the network.""" + node_id = service.data.get(const.ATTR_NODE_ID) + update_return_routes = service.data.get(const.ATTR_RETURN_ROUTES) + node = network.nodes[node_id] + _LOGGER.info("Z-Wave node heal running for node %s", node_id) + node.heal(update_return_routes) + + def test_node(service): + """Send test messages to a node on the network.""" + node_id = service.data.get(const.ATTR_NODE_ID) + messages = service.data.get(const.ATTR_MESSAGES) + node = network.nodes[node_id] + _LOGGER.info("Sending %s test-messages to node %s.", messages, node_id) + node.test(messages) + def start_zwave(_service_or_event): """Startup Z-Wave network.""" _LOGGER.info("Starting Z-Wave network...") @@ -684,6 +711,16 @@ def setup(hass, config): set_poll_intensity, descriptions[const.SERVICE_SET_POLL_INTENSITY], schema=SET_POLL_INTENSITY_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_HEAL_NODE, + heal_node, + descriptions[ + const.SERVICE_HEAL_NODE], + schema=HEAL_NODE_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_TEST_NODE, + test_node, + descriptions[ + const.SERVICE_TEST_NODE], + schema=TEST_NODE_SCHEMA) # Setup autoheal if autoheal: diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index dced1689dba..5f0a7f4750b 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -8,7 +8,9 @@ ATTR_INSTANCE = "instance" ATTR_GROUP = "group" ATTR_VALUE_ID = "value_id" ATTR_OBJECT_ID = "object_id" +ATTR_MESSAGES = "messages" ATTR_NAME = "name" +ATTR_RETURN_ROUTES = "return_routes" ATTR_SCENE_ID = "scene_id" ATTR_SCENE_DATA = "scene_data" ATTR_BASIC_LEVEL = "basic_level" @@ -32,7 +34,9 @@ SERVICE_ADD_NODE_SECURE = "add_node_secure" SERVICE_REMOVE_NODE = "remove_node" SERVICE_CANCEL_COMMAND = "cancel_command" SERVICE_HEAL_NETWORK = "heal_network" +SERVICE_HEAL_NODE = "heal_node" SERVICE_SOFT_RESET = "soft_reset" +SERVICE_TEST_NODE = "test_node" SERVICE_TEST_NETWORK = "test_network" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_PRINT_CONFIG_PARAMETER = "print_config_parameter" diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 06e317333be..ba8e177c9f7 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -28,6 +28,17 @@ cancel_command: heal_network: description: Start a Z-Wave network heal. This might take a while and will slow down the Z-Wave network greatly while it is being processed. Refer to OZW.log for progress. + fields: + return_routes: + description: Wheter or not to update the return routes from the nodes to the controller. Defaults to False. + example: True + +heal_node: + description: Start a Z-Wave node heal. Refer to OZW.log for progress. + fields: + return_routes: + description: Wheter or not to update the return routes from the node to the controller. Defaults to False. + example: True remove_node: description: Remove a node from the Z-Wave network. Refer to OZW.log for progress. @@ -120,6 +131,16 @@ soft_reset: test_network: description: This will send test to nodes in the Z-Wave network. This will greatly slow down the Z-Wave network while it is being processed. Refer to OZW.log for progress. +test_node: + description: This will send test messages to a node in the Z-Wave network. This could bring back dead nodes. + fields: + node_id: + description: ID of the node to send test messages to. + example: 10 + messages: + description: Optional. Amount of test messages to send. + example: 3 + rename_node: description: Set the name of a node. This will also affect the IDs of all entities in the node. fields: diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 1e759949a46..ce2795297a2 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -1253,3 +1253,27 @@ class TestZWaveServices(unittest.TestCase): assert node.refresh_info.called assert len(node.refresh_info.mock_calls) == 1 + + def test_heal_node(self): + """Test zwave heal_node service.""" + node = MockNode(node_id=19) + self.zwave_network.nodes = {19: node} + self.hass.services.call('zwave', 'heal_node', { + const.ATTR_NODE_ID: 19, + }) + self.hass.block_till_done() + + assert node.heal.called + assert len(node.heal.mock_calls) == 1 + + def test_test_node(self): + """Test the zwave test_node service.""" + node = MockNode(node_id=19) + self.zwave_network.nodes = {19: node} + self.hass.services.call('zwave', 'test_node', { + const.ATTR_NODE_ID: 19, + }) + self.hass.block_till_done() + + assert node.test.called + assert len(node.test.mock_calls) == 1 From 5d4514652dd61b94202530fb832543f81982ac72 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 5 Nov 2017 19:25:44 +0100 Subject: [PATCH 030/137] Addition of new binary sensor class 'plug' (#10336) * Addition of new binary sensor class 'plug' * use term "unplugged" * add the entry to the right place --- homeassistant/components/binary_sensor/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 4ba29e9b2ba..baf9c41cfdf 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -30,6 +30,7 @@ DEVICE_CLASSES = [ 'moving', # On means moving, Off means stopped 'occupancy', # On means occupied, Off means not occupied 'opening', # Door, window, etc. + 'plug', # On means plugged in, Off means unplugged 'power', # Power, over-current, etc 'safety', # Generic on=unsafe, off=safe 'smoke', # Smoke detector From f3511d615e7eddc90307a5d0f7dec94d954de8e6 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 5 Nov 2017 22:52:58 +0100 Subject: [PATCH 031/137] Upgrae simplepush to 1.1.4 (#10365) --- homeassistant/components/notify/simplepush.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/simplepush.py b/homeassistant/components/notify/simplepush.py index b4c65d116c4..9d5c58fc5b1 100644 --- a/homeassistant/components/notify/simplepush.py +++ b/homeassistant/components/notify/simplepush.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_PASSWORD -REQUIREMENTS = ['simplepush==1.1.3'] +REQUIREMENTS = ['simplepush==1.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d259fdd5014..3e6ca00b3c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -993,7 +993,7 @@ sharp_aquos_rc==0.3.2 shodan==1.7.5 # homeassistant.components.notify.simplepush -simplepush==1.1.3 +simplepush==1.1.4 # homeassistant.components.alarm_control_panel.simplisafe simplisafe-python==1.0.5 From 39de557c4ce23711c1af22d48812fe99d8b241dc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Nov 2017 18:26:16 -0800 Subject: [PATCH 032/137] Update frontend --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f9c9e2ddcaf..224970499f3 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171105.0'] +REQUIREMENTS = ['home-assistant-frontend==20171106.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api'] diff --git a/requirements_all.txt b/requirements_all.txt index 966027c9739..974191eb8ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,7 +330,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171105.0 +home-assistant-frontend==20171106.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 384dac3cf8e..b0b6c5c5493 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171105.0 +home-assistant-frontend==20171106.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From a9a3e24bde6363dec190b9e9c6907a6c8251a968 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 6 Nov 2017 03:42:31 +0100 Subject: [PATCH 033/137] Update aiohttp to 2.3.1 (#10139) * Update aiohttp to 2.3.1 * set timeout 10sec * fix freeze with new middleware handling * Convert middleware auth * Convert mittleware ipban * convert middleware static * fix lint * Update ban.py * Update auth.py * fix lint * Fix tests --- homeassistant/components/http/__init__.py | 10 ++-- homeassistant/components/http/auth.py | 63 ++++++++++------------- homeassistant/components/http/ban.py | 40 +++++++------- homeassistant/components/http/static.py | 26 ++++------ homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- tests/components/http/test_init.py | 14 +---- 8 files changed, 67 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 5dda8f1825d..659fd026bb8 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -182,8 +182,6 @@ class HomeAssistantWSGI(object): use_x_forwarded_for, trusted_networks, login_threshold, is_ban_enabled): """Initialize the WSGI Home Assistant server.""" - import aiohttp_cors - middlewares = [auth_middleware, staticresource_middleware] if is_ban_enabled: @@ -206,6 +204,8 @@ class HomeAssistantWSGI(object): self.server = None if cors_origins: + import aiohttp_cors + self.cors = aiohttp_cors.setup(self.app, defaults={ host: aiohttp_cors.ResourceOptions( allow_headers=ALLOWED_CORS_HEADERS, @@ -335,7 +335,9 @@ class HomeAssistantWSGI(object): _LOGGER.error("Failed to create HTTP server at port %d: %s", self.server_port, error) - self.app._frozen = False # pylint: disable=protected-access + # pylint: disable=protected-access + self.app._middlewares = tuple(self.app._prepare_middleware()) + self.app._frozen = False @asyncio.coroutine def stop(self): @@ -345,7 +347,7 @@ class HomeAssistantWSGI(object): yield from self.server.wait_closed() yield from self.app.shutdown() if self._handler: - yield from self._handler.finish_connections(60.0) + yield from self._handler.shutdown(10) yield from self.app.cleanup() diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 4b971c883d3..ce5bfca3ac1 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -5,6 +5,7 @@ import hmac import logging from aiohttp import hdrs +from aiohttp.web import middleware from homeassistant.const import HTTP_HEADER_HA_AUTH from .util import get_real_ip @@ -15,47 +16,37 @@ DATA_API_PASSWORD = 'api_password' _LOGGER = logging.getLogger(__name__) +@middleware @asyncio.coroutine -def auth_middleware(app, handler): +def auth_middleware(request, handler): """Authenticate as middleware.""" # If no password set, just always set authenticated=True - if app['hass'].http.api_password is None: - @asyncio.coroutine - def no_auth_middleware_handler(request): - """Auth middleware to approve all requests.""" - request[KEY_AUTHENTICATED] = True - return handler(request) - - return no_auth_middleware_handler - - @asyncio.coroutine - def auth_middleware_handler(request): - """Auth middleware to check authentication.""" - # Auth code verbose on purpose - authenticated = False - - if (HTTP_HEADER_HA_AUTH in request.headers and - validate_password( - request, request.headers[HTTP_HEADER_HA_AUTH])): - # A valid auth header has been set - authenticated = True - - elif (DATA_API_PASSWORD in request.query and - validate_password(request, request.query[DATA_API_PASSWORD])): - authenticated = True - - elif (hdrs.AUTHORIZATION in request.headers and - validate_authorization_header(request)): - authenticated = True - - elif is_trusted_ip(request): - authenticated = True - - request[KEY_AUTHENTICATED] = authenticated - + if request.app['hass'].http.api_password is None: + request[KEY_AUTHENTICATED] = True return handler(request) - return auth_middleware_handler + # Check authentication + authenticated = False + + if (HTTP_HEADER_HA_AUTH in request.headers and + validate_password( + request, request.headers[HTTP_HEADER_HA_AUTH])): + # A valid auth header has been set + authenticated = True + + elif (DATA_API_PASSWORD in request.query and + validate_password(request, request.query[DATA_API_PASSWORD])): + authenticated = True + + elif (hdrs.AUTHORIZATION in request.headers and + validate_authorization_header(request)): + authenticated = True + + elif is_trusted_ip(request): + authenticated = True + + request[KEY_AUTHENTICATED] = authenticated + return handler(request) def is_trusted_ip(request): diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index aa01ccde8d7..f636ad80c36 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -6,6 +6,7 @@ from ipaddress import ip_address import logging import os +from aiohttp.web import middleware from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol @@ -32,35 +33,32 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({ }) +@middleware @asyncio.coroutine -def ban_middleware(app, handler): +def ban_middleware(request, handler): """IP Ban middleware.""" - if not app[KEY_BANS_ENABLED]: - return handler + if not request.app[KEY_BANS_ENABLED]: + return (yield from handler(request)) - if KEY_BANNED_IPS not in app: - hass = app['hass'] - app[KEY_BANNED_IPS] = yield from hass.async_add_job( + if KEY_BANNED_IPS not in request.app: + hass = request.app['hass'] + request.app[KEY_BANNED_IPS] = yield from hass.async_add_job( load_ip_bans_config, hass.config.path(IP_BANS_FILE)) - @asyncio.coroutine - def ban_middleware_handler(request): - """Verify if IP is not banned.""" - ip_address_ = get_real_ip(request) + # Verify if IP is not banned + ip_address_ = get_real_ip(request) - is_banned = any(ip_ban.ip_address == ip_address_ - for ip_ban in request.app[KEY_BANNED_IPS]) + is_banned = any(ip_ban.ip_address == ip_address_ + for ip_ban in request.app[KEY_BANNED_IPS]) - if is_banned: - raise HTTPForbidden() + if is_banned: + raise HTTPForbidden() - try: - return (yield from handler(request)) - except HTTPUnauthorized: - yield from process_wrong_login(request) - raise - - return ban_middleware_handler + try: + return (yield from handler(request)) + except HTTPUnauthorized: + yield from process_wrong_login(request) + raise @asyncio.coroutine diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index f991a4ee0fc..c2576358f59 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -3,7 +3,7 @@ import asyncio import re from aiohttp import hdrs -from aiohttp.web import FileResponse +from aiohttp.web import FileResponse, middleware from aiohttp.web_exceptions import HTTPNotFound from aiohttp.web_urldispatcher import StaticResource from yarl import unquote @@ -61,21 +61,17 @@ class CachingFileResponse(FileResponse): self._sendfile = sendfile +@middleware @asyncio.coroutine -def staticresource_middleware(app, handler): +def staticresource_middleware(request, handler): """Middleware to strip out fingerprint from fingerprinted assets.""" - @asyncio.coroutine - def static_middleware_handler(request): - """Strip out fingerprints from resource names.""" - if not request.path.startswith('/static/'): - return handler(request) - - fingerprinted = _FINGERPRINT.match(request.match_info['filename']) - - if fingerprinted: - request.match_info['filename'] = \ - '{}.{}'.format(*fingerprinted.groups()) - + if not request.path.startswith('/static/'): return handler(request) - return static_middleware_handler + fingerprinted = _FINGERPRINT.match(request.match_info['filename']) + + if fingerprinted: + request.match_info['filename'] = \ + '{}.{}'.format(*fingerprinted.groups()) + + return handler(request) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7da87160684..00df81290e5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.2.5 +aiohttp==2.3.1 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 974191eb8ba..87025dd7e7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,7 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.2.5 +aiohttp==2.3.1 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/setup.py b/setup.py index 2bb78877f6d..25c38af27fb 100755 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ REQUIRES = [ 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.2.5', + 'aiohttp==2.3.1', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index f547306ff82..4ff87efd137 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -143,22 +143,10 @@ def test_registering_view_while_running(hass, test_client): } ) - yield from setup.async_setup_component(hass, 'api') - yield from hass.async_start() - - yield from hass.async_block_till_done() - + # This raises a RuntimeError if app is frozen hass.http.register_view(TestView) - client = yield from test_client(hass.http.app) - - resp = yield from client.get('/hello') - assert resp.status == 200 - - text = yield from resp.text() - assert text == 'hello' - @asyncio.coroutine def test_api_base_url_with_domain(hass): From 131af1fece3ac0e6ef5b6bf7ca8716d73b5ba073 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Mon, 6 Nov 2017 09:20:31 +0100 Subject: [PATCH 034/137] Device model identification of the Xiaomi Philips Ceiling Lamp fixed. (#10401) --- homeassistant/components/light/xiaomi_miio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index d7d0900ed28..df716bcf1e9 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -64,7 +64,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): light = PhilipsEyecare(host, token) device = XiaomiPhilipsEyecareLamp(name, light, device_info) devices.append(device) - elif device_info.model == 'philips.light.ceil': + elif device_info.model == 'philips.light.ceiling': from miio import Ceil light = Ceil(host, token) device = XiaomiPhilipsCeilingLamp(name, light, device_info) From 5410700708dee1d32028de831459914d21e2f26b Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Mon, 6 Nov 2017 15:15:52 +0100 Subject: [PATCH 035/137] Zwave save cache to file now. (#10381) * Add save config * Add API to save Z-Wave cache to file immediatley. * lint * remove none assignment * docstring --- homeassistant/components/config/zwave.py | 26 ++++++++++++++++-- tests/components/config/test_zwave.py | 35 +++++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index 53fa200a1b1..dd8552f374e 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -1,14 +1,15 @@ """Provide configuration end points for Z-Wave.""" import asyncio +import logging import homeassistant.core as ha -from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.const import HTTP_NOT_FOUND, HTTP_OK from homeassistant.components.http import HomeAssistantView from homeassistant.components.config import EditKeyBasedConfigView from homeassistant.components.zwave import const, DEVICE_CONFIG_SCHEMA_ENTRY import homeassistant.helpers.config_validation as cv - +_LOGGER = logging.getLogger(__name__) CONFIG_PATH = 'zwave_device_config.yaml' OZW_LOG_FILENAME = 'OZW_Log.txt' URL_API_OZW_LOG = '/api/zwave/ozwlog' @@ -27,10 +28,31 @@ def async_setup(hass): hass.http.register_view(ZWaveUserCodeView) hass.http.register_static_path( URL_API_OZW_LOG, hass.config.path(OZW_LOG_FILENAME), False) + hass.http.register_view(ZWaveConfigWriteView) return True +class ZWaveConfigWriteView(HomeAssistantView): + """View to save the ZWave configuration to zwcfg_xxxxx.xml.""" + + url = "/api/zwave/saveconfig" + name = "api:zwave:saveconfig" + + @ha.callback + def post(self, request): + """Save cache configuration to zwcfg_xxxxx.xml.""" + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + if network is None: + return self.json_message('No Z-Wave network data found', + HTTP_NOT_FOUND) + _LOGGER.info("Z-Wave configuration written to file.") + network.write_config() + return self.json_message('Z-Wave configuration saved to file.', + HTTP_OK) + + class ZWaveNodeValueView(HomeAssistantView): """View to return the node values.""" diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index fc359dc7ff7..81800d709e3 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -9,7 +9,7 @@ from homeassistant.components import config from homeassistant.components.zwave import DATA_NETWORK, const from homeassistant.components.config.zwave import ( ZWaveNodeValueView, ZWaveNodeGroupView, ZWaveNodeConfigView, - ZWaveUserCodeView) + ZWaveUserCodeView, ZWaveConfigWriteView) from tests.common import mock_http_component_app from tests.mock.zwave import MockNode, MockValue, MockEntityValues @@ -417,3 +417,36 @@ def test_get_usercodes_no_genreuser(hass, test_client): result = yield from resp.json() assert result == {} + + +@asyncio.coroutine +def test_save_config_no_network(hass, test_client): + """Test saving configuration without network data.""" + app = mock_http_component_app(hass) + ZWaveConfigWriteView().register(app.router) + + client = yield from test_client(app) + + resp = yield from client.post('/api/zwave/saveconfig') + + assert resp.status == 404 + result = yield from resp.json() + assert result == {'message': 'No Z-Wave network data found'} + + +@asyncio.coroutine +def test_save_config(hass, test_client): + """Test saving configuration.""" + app = mock_http_component_app(hass) + ZWaveConfigWriteView().register(app.router) + + network = hass.data[DATA_NETWORK] = MagicMock() + + client = yield from test_client(app) + + resp = yield from client.post('/api/zwave/saveconfig') + + assert resp.status == 200 + result = yield from resp.json() + assert network.write_config.called + assert result == {'message': 'Z-Wave configuration saved to file.'} From 07f073361fa229c753a7a2ed3b314d98caaa78dd Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Mon, 6 Nov 2017 16:39:13 -0800 Subject: [PATCH 036/137] Bump to 0.12.2 to fix urllib3 dependency (#10420) --- 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 581045c3790..b4c6adcc887 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -21,7 +21,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['abodepy==0.12.1'] +REQUIREMENTS = ['abodepy==0.12.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 87025dd7e7a..9c723b6f43d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -58,7 +58,7 @@ TwitterAPI==2.4.6 YesssSMS==0.1.1b3 # homeassistant.components.abode -abodepy==0.12.1 +abodepy==0.12.2 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.4 From 11ecc2c17134ffad078a978f8c750d77001acedf Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Tue, 7 Nov 2017 10:13:39 -0500 Subject: [PATCH 037/137] Remove extra info from zwave entity states (#10413) * Remove extra info from zwave entity states * Show initializing for nodes that haven't completed queries --- homeassistant/components/zwave/node_entity.py | 21 +++++++------------ tests/components/zwave/test_node_entity.py | 20 +++++++----------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 44a30cdc529..04446cff9a1 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -69,11 +69,6 @@ class ZWaveBaseEntity(Entity): self.hass.loop.call_later(0.1, do_update) -def sub_status(status, stage): - """Format sub-status.""" - return '{} ({})'.format(status, stage) if stage else status - - class ZWaveNodeEntity(ZWaveBaseEntity): """Representation of a Z-Wave node.""" @@ -201,17 +196,17 @@ class ZWaveNodeEntity(ZWaveBaseEntity): """Return the state.""" if ATTR_READY not in self._attributes: return None - stage = '' - if not self._attributes[ATTR_READY]: - # If node is not ready use stage as sub-status. - stage = self._attributes[ATTR_QUERY_STAGE] + if self._attributes[ATTR_FAILED]: - return sub_status('Dead', stage) + return 'dead' + if self._attributes[ATTR_QUERY_STAGE] != 'Complete': + return 'initializing' if not self._attributes[ATTR_AWAKE]: - return sub_status('Sleeping', stage) + return 'sleeping' if self._attributes[ATTR_READY]: - return sub_status('Ready', stage) - return stage + return 'ready' + + return None @property def should_poll(self): diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 32351234ad3..e4afca31740 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -330,38 +330,34 @@ class TestZWaveNodeEntity(unittest.TestCase): """Test state property.""" self.node.is_ready = False self.entity.node_changed() - self.assertEqual('Dynamic', self.entity.state) + self.assertEqual('initializing', self.entity.state) self.node.is_failed = True + self.node.query_stage = 'Complete' self.entity.node_changed() - self.assertEqual('Dead (Dynamic)', self.entity.state) + self.assertEqual('dead', self.entity.state) self.node.is_failed = False self.node.is_awake = False self.entity.node_changed() - self.assertEqual('Sleeping (Dynamic)', self.entity.state) + self.assertEqual('sleeping', self.entity.state) def test_state_ready(self): """Test state property.""" + self.node.query_stage = 'Complete' self.node.is_ready = True self.entity.node_changed() - self.assertEqual('Ready', self.entity.state) + self.assertEqual('ready', self.entity.state) self.node.is_failed = True self.entity.node_changed() - self.assertEqual('Dead', self.entity.state) + self.assertEqual('dead', self.entity.state) self.node.is_failed = False self.node.is_awake = False self.entity.node_changed() - self.assertEqual('Sleeping', self.entity.state) + self.assertEqual('sleeping', self.entity.state) def test_not_polled(self): """Test should_poll property.""" self.assertFalse(self.entity.should_poll) - - -def test_sub_status(): - """Test sub_status function.""" - assert node_entity.sub_status('Status', 'Stage') == 'Status (Stage)' - assert node_entity.sub_status('Status', '') == 'Status' From 119fb08198a698a5d7d477ae88917882adada1dc Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 7 Nov 2017 17:19:54 +0000 Subject: [PATCH 038/137] Fixes issue #10425 (#10426) Fixes an error reported resulting from Hammersmith no longer supplying data. --- homeassistant/components/sensor/london_air.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/sensor/london_air.py b/homeassistant/components/sensor/london_air.py index 7a8ad4087b0..848e1255833 100644 --- a/homeassistant/components/sensor/london_air.py +++ b/homeassistant/components/sensor/london_air.py @@ -31,7 +31,6 @@ AUTHORITIES = [ 'Enfield', 'Greenwich', 'Hackney', - 'Hammersmith and Fulham', 'Haringey', 'Harrow', 'Havering', From a5aa1118937702ca8bec050614ee52dc14f8466b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 7 Nov 2017 21:06:19 +0000 Subject: [PATCH 039/137] Add baudrate option to Serial sensor (#10439) * Add baudrate option Baudrate is essential! * line too long line too long (82 > 79 characters) * trailing whitespace * Rename const * Fix the missing one --- homeassistant/components/sensor/serial.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/serial.py b/homeassistant/components/sensor/serial.py index ffa8bcc3070..6f01a8f856b 100644 --- a/homeassistant/components/sensor/serial.py +++ b/homeassistant/components/sensor/serial.py @@ -19,11 +19,15 @@ REQUIREMENTS = ['pyserial-asyncio==0.4'] _LOGGER = logging.getLogger(__name__) CONF_SERIAL_PORT = 'serial_port' +CONF_BAUDRATE = 'baudrate' DEFAULT_NAME = "Serial Sensor" +DEFAULT_BAUDRATE = 9600 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SERIAL_PORT): cv.string, + vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): + cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -33,8 +37,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Serial sensor platform.""" name = config.get(CONF_NAME) port = config.get(CONF_SERIAL_PORT) + baudrate = config.get(CONF_BAUDRATE) - sensor = SerialSensor(name, port) + sensor = SerialSensor(name, port, baudrate) hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, sensor.stop_serial_read()) @@ -44,25 +49,26 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class SerialSensor(Entity): """Representation of a Serial sensor.""" - def __init__(self, name, port): + def __init__(self, name, port, baudrate): """Initialize the Serial sensor.""" self._name = name self._state = None self._port = port + self._baudrate = baudrate self._serial_loop_task = None @asyncio.coroutine def async_added_to_hass(self): """Handle when an entity is about to be added to Home Assistant.""" self._serial_loop_task = self.hass.loop.create_task( - self.serial_read(self._port)) + self.serial_read(self._port, self._baudrate)) @asyncio.coroutine - def serial_read(self, device, **kwargs): + def serial_read(self, device, rate, **kwargs): """Read the data from the port.""" import serial_asyncio reader, _ = yield from serial_asyncio.open_serial_connection( - url=device, **kwargs) + url=device, baudrate=rate, **kwargs) while True: line = yield from reader.readline() self._state = line.decode('utf-8').strip() From 50f6790a27f3e8901edb833ed27a2db25daea7f2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Nov 2017 21:28:11 -0800 Subject: [PATCH 040/137] Remove model info from state (#10399) --- homeassistant/components/light/tradfri.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index 63441e6d8af..c3632351e5f 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -8,6 +8,7 @@ import asyncio import logging from homeassistant.core import callback +from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, @@ -181,14 +182,12 @@ class TradfriLight(Light): def device_state_attributes(self): """Return the devices' state attributes.""" info = self._light.device_info - attrs = { - 'manufacturer': info.manufacturer, - 'model_number': info.model_number, - 'serial': info.serial, - 'firmware_version': info.firmware_version, - 'power_source': info.power_source_str, - 'battery_level': info.battery_level - } + + attrs = {} + + if info.battery_level is not None: + attrs[ATTR_BATTERY_LEVEL] = info.battery_level + return attrs @asyncio.coroutine From e49278cc7db09f1a9def81c5aac185e3e6680f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 8 Nov 2017 11:18:35 +0100 Subject: [PATCH 041/137] update tibber library (#10460) --- homeassistant/components/sensor/tibber.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index f1edaa37f77..dd09b9f7891 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -REQUIREMENTS = ['pyTibber==0.1.1'] +REQUIREMENTS = ['pyTibber==0.2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9c723b6f43d..6c1da0a0caf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -590,7 +590,7 @@ pyHS100==0.3.0 pyRFXtrx==0.20.1 # homeassistant.components.sensor.tibber -pyTibber==0.1.1 +pyTibber==0.2.1 # homeassistant.components.switch.dlink pyW215==0.6.0 From db8510f1102227e2e46c36f0b39bd7a6881c8883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Wed, 8 Nov 2017 12:02:28 +0100 Subject: [PATCH 042/137] update pywebpush==1.3.0 (#10374) --- homeassistant/components/notify/html5.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index cb81ef55865..a05c061c515 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -26,7 +26,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string -REQUIREMENTS = ['pywebpush==1.1.0', 'PyJWT==1.5.3'] +REQUIREMENTS = ['pywebpush==1.3.0', 'PyJWT==1.5.3'] DEPENDENCIES = ['frontend'] diff --git a/requirements_all.txt b/requirements_all.txt index 6c1da0a0caf..26598116e22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -914,7 +914,7 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.1.0 +pywebpush==1.3.0 # homeassistant.components.wemo pywemo==0.4.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0b6c5c5493..14dea3f25cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -134,7 +134,7 @@ pythonwhois==2.4.3 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.1.0 +pywebpush==1.3.0 # homeassistant.components.python_script restrictedpython==4.0b2 From 2e5b1e76efe371dda777da141671874fd679ae4d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Nov 2017 03:38:17 -0800 Subject: [PATCH 043/137] Fix slow WOL switch test (#10455) --- tests/components/switch/test_wake_on_lan.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/switch/test_wake_on_lan.py b/tests/components/switch/test_wake_on_lan.py index 063cf93d871..3042535ff42 100644 --- a/tests/components/switch/test_wake_on_lan.py +++ b/tests/components/switch/test_wake_on_lan.py @@ -6,7 +6,7 @@ from homeassistant.setup import setup_component from homeassistant.const import STATE_ON, STATE_OFF import homeassistant.components.switch as switch -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_service TEST_STATE = None @@ -141,6 +141,7 @@ class TestWOLSwitch(unittest.TestCase): }, } })) + calls = mock_service(self.hass, 'shell_command', 'turn_off_TARGET') state = self.hass.states.get('switch.wake_on_lan') self.assertEqual(STATE_OFF, state.state) @@ -152,6 +153,7 @@ class TestWOLSwitch(unittest.TestCase): state = self.hass.states.get('switch.wake_on_lan') self.assertEqual(STATE_ON, state.state) + assert len(calls) == 0 TEST_STATE = False @@ -160,6 +162,7 @@ class TestWOLSwitch(unittest.TestCase): state = self.hass.states.get('switch.wake_on_lan') self.assertEqual(STATE_OFF, state.state) + assert len(calls) == 1 @patch('wakeonlan.wol.send_magic_packet', new=send_magic_packet) @patch('subprocess.call', new=call) From 2f0920e4fb57ebbe224b7f018f67b9f2170f5aa2 Mon Sep 17 00:00:00 2001 From: Milan V Date: Wed, 8 Nov 2017 14:43:15 +0100 Subject: [PATCH 044/137] Fix recorder stop on SQLite vacuuming error (#10405) * Fix recorder stop on SQLite vacuuming error * Move import to function --- homeassistant/components/recorder/purge.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 90a69f8f2a1..719f65abb47 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -28,5 +28,10 @@ def purge_old_data(instance, purge_days): # Execute sqlite vacuum command to free up space on disk _LOGGER.debug("DB engine driver: %s", instance.engine.driver) if instance.engine.driver == 'pysqlite': + from sqlalchemy import exc + _LOGGER.info("Vacuuming SQLite to free space") - instance.engine.execute("VACUUM") + try: + instance.engine.execute("VACUUM") + except exc.OperationalError as err: + _LOGGER.error("Error vacuuming SQLite: %s.", err) From 148a7ddda94210f2c5839da40b321b93368d3a25 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 8 Nov 2017 08:54:12 -0600 Subject: [PATCH 045/137] Add include/exclude filter to mqtt_statestream (#10354) * Add publish filter to mqtt_statestream * Add tests for include/excludes in mqtt_statestream --- homeassistant/components/mqtt_statestream.py | 16 +- tests/components/test_mqtt_statestream.py | 242 ++++++++++++++++++- 2 files changed, 254 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py index d24361637e9..fa1da879110 100644 --- a/homeassistant/components/mqtt_statestream.py +++ b/homeassistant/components/mqtt_statestream.py @@ -9,9 +9,11 @@ import json import voluptuous as vol -from homeassistant.const import MATCH_ALL +from homeassistant.const import (CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, + CONF_INCLUDE, MATCH_ALL) from homeassistant.core import callback from homeassistant.components.mqtt import valid_publish_topic +from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.event import async_track_state_change from homeassistant.remote import JSONEncoder import homeassistant.helpers.config_validation as cv @@ -23,7 +25,7 @@ DEPENDENCIES = ['mqtt'] DOMAIN = 'mqtt_statestream' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ + DOMAIN: cv.FILTER_SCHEMA.extend({ vol.Required(CONF_BASE_TOPIC): valid_publish_topic, vol.Optional(CONF_PUBLISH_ATTRIBUTES, default=False): cv.boolean, vol.Optional(CONF_PUBLISH_TIMESTAMPS, default=False): cv.boolean @@ -36,8 +38,14 @@ def async_setup(hass, config): """Set up the MQTT state feed.""" conf = config.get(DOMAIN, {}) base_topic = conf.get(CONF_BASE_TOPIC) + pub_include = conf.get(CONF_INCLUDE, {}) + pub_exclude = conf.get(CONF_EXCLUDE, {}) publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES) publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS) + publish_filter = generate_filter(pub_include.get(CONF_DOMAINS, []), + pub_include.get(CONF_ENTITIES, []), + pub_exclude.get(CONF_DOMAINS, []), + pub_exclude.get(CONF_ENTITIES, [])) if not base_topic.endswith('/'): base_topic = base_topic + '/' @@ -45,6 +53,10 @@ def async_setup(hass, config): def _state_publisher(entity_id, old_state, new_state): if new_state is None: return + + if not publish_filter(entity_id): + return + payload = new_state.state mybase = base_topic + entity_id.replace('.', '/') + '/' diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py index cc1ea277a34..76d8e48d03a 100644 --- a/tests/components/test_mqtt_statestream.py +++ b/tests/components/test_mqtt_statestream.py @@ -25,7 +25,8 @@ class TestMqttStateStream(object): self.hass.stop() def add_statestream(self, base_topic=None, publish_attributes=None, - publish_timestamps=None): + publish_timestamps=None, publish_include=None, + publish_exclude=None): """Add a mqtt_statestream component.""" config = {} if base_topic: @@ -34,7 +35,10 @@ class TestMqttStateStream(object): config['publish_attributes'] = publish_attributes if publish_timestamps: config['publish_timestamps'] = publish_timestamps - print("Publishing timestamps") + if publish_include: + config['include'] = publish_include + if publish_exclude: + config['exclude'] = publish_exclude return setup_component(self.hass, statestream.DOMAIN, { statestream.DOMAIN: config}) @@ -152,3 +156,237 @@ class TestMqttStateStream(object): mock_pub.assert_has_calls(calls, any_order=True) assert mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_include_domain(self, mock_utcnow, mock_pub): + """"Test that filtering on included domain works as expected.""" + base_topic = 'pub' + + incl = { + 'domains': ['fake'] + } + excl = {} + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + 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('fake.entity', 'on')) + self.hass.block_till_done() + + # 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 + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake2.entity', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_include_entity(self, mock_utcnow, mock_pub): + """"Test that filtering on included entity works as expected.""" + base_topic = 'pub' + + incl = { + 'entities': ['fake.entity'] + } + excl = {} + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + 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('fake.entity', 'on')) + self.hass.block_till_done() + + # 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 + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake.entity2', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_exclude_domain(self, mock_utcnow, mock_pub): + """"Test that filtering on excluded domain works as expected.""" + base_topic = 'pub' + + incl = {} + excl = { + 'domains': ['fake2'] + } + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + 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('fake.entity', 'on')) + self.hass.block_till_done() + + # 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 + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake2.entity', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_exclude_entity(self, mock_utcnow, mock_pub): + """"Test that filtering on excluded entity works as expected.""" + base_topic = 'pub' + + incl = {} + excl = { + 'entities': ['fake.entity2'] + } + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + 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('fake.entity', 'on')) + self.hass.block_till_done() + + # 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 + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake.entity2', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_exclude_domain_include_entity( + self, mock_utcnow, mock_pub): + """"Test filtering with excluded domain and included entity.""" + base_topic = 'pub' + + incl = { + 'entities': ['fake.entity'] + } + excl = { + 'domains': ['fake'] + } + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + 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('fake.entity', 'on')) + self.hass.block_till_done() + + # 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 + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake.entity2', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_include_domain_exclude_entity( + self, mock_utcnow, mock_pub): + """"Test filtering with included domain and excluded entity.""" + base_topic = 'pub' + + incl = { + 'domains': ['fake'] + } + excl = { + 'entities': ['fake.entity2'] + } + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + 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('fake.entity', 'on')) + self.hass.block_till_done() + + # 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 + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake.entity2', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called From f5ea7d3c9c602244e357c0457c4a911b85acc6aa Mon Sep 17 00:00:00 2001 From: TopdRob Date: Wed, 8 Nov 2017 16:11:12 +0100 Subject: [PATCH 046/137] Upgrade to 0.1.2 (#10348) Fix an insecure request warning when not using verify=True. Contributed by @nalepae --- homeassistant/components/notify/free_mobile.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/free_mobile.py b/homeassistant/components/notify/free_mobile.py index 92ea75a79dc..a27d0495193 100644 --- a/homeassistant/components/notify/free_mobile.py +++ b/homeassistant/components/notify/free_mobile.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['freesms==0.1.1'] +REQUIREMENTS = ['freesms==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 26598116e22..e818c0d88d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,7 +265,7 @@ fixerio==0.1.1 flux_led==0.20 # homeassistant.components.notify.free_mobile -freesms==0.1.1 +freesms==0.1.2 # homeassistant.components.device_tracker.fritz # homeassistant.components.sensor.fritzbox_callmonitor From ed9abe3fa2d0b255dd33333a834c4a1d9c1aea40 Mon Sep 17 00:00:00 2001 From: TopdRob Date: Wed, 8 Nov 2017 16:13:05 +0100 Subject: [PATCH 047/137] Upgrade pyatv to 0.3.6 (#10349) Fix string conversion for idle state --- homeassistant/components/apple_tv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 5e02f80f229..6e38f172c4c 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.5'] +REQUIREMENTS = ['pyatv==0.3.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e818c0d88d8..a85fcbea2f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -611,7 +611,7 @@ pyasn1-modules==0.1.5 pyasn1==0.3.7 # homeassistant.components.apple_tv -pyatv==0.3.5 +pyatv==0.3.6 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox From 2fff065b2cfb1f9bf90dc9fd78506717025108d4 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 9 Nov 2017 00:46:33 +0100 Subject: [PATCH 048/137] Remove useless temp converting (#10465) --- homeassistant/components/climate/homematic.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index ce6e9580e54..5236c0788fd 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/climate.homematic/ import logging from homeassistant.components.climate import ClimateDevice, STATE_AUTO from homeassistant.components.homematic import HMDevice, ATTR_DISCOVER_DEVICES -from homeassistant.util.temperature import convert from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN, ATTR_TEMPERATURE DEPENDENCIES = ['homematic'] @@ -121,12 +120,12 @@ class HMThermostat(HMDevice, ClimateDevice): @property def min_temp(self): """Return the minimum temperature - 4.5 means off.""" - return convert(4.5, TEMP_CELSIUS, self.unit_of_measurement) + return 4.5 @property def max_temp(self): """Return the maximum temperature - 30.5 means on.""" - return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) + return 30.5 def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" From 2118ab250334319528e48c295e0ca7d10b1dc37f Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Wed, 8 Nov 2017 19:01:20 -0500 Subject: [PATCH 049/137] Fixed update() method and removed `ding` feature from stickupcams/floodlight (#10428) * Simplified URL expiration calculation and fixed refresh method * Remove support from Ring from StickupCams or floodlight cameras * Makes lint happy * Removed unecessary attributes --- .../components/binary_sensor/ring.py | 2 +- homeassistant/components/camera/ring.py | 30 ++++++++++--------- homeassistant/components/sensor/ring.py | 2 +- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 1e926f00a2f..e84009301ab 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -27,7 +27,7 @@ SCAN_INTERVAL = timedelta(seconds=5) # Sensor types: Name, category, device_class SENSOR_TYPES = { - 'ding': ['Ding', ['doorbell', 'stickup_cams'], 'occupancy'], + 'ding': ['Ding', ['doorbell'], 'occupancy'], 'motion': ['Motion', ['doorbell', 'stickup_cams'], 'motion'], } diff --git a/homeassistant/components/camera/ring.py b/homeassistant/components/camera/ring.py index 70569825764..a5e9855bf37 100644 --- a/homeassistant/components/camera/ring.py +++ b/homeassistant/components/camera/ring.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/camera.ring/ import asyncio import logging -from datetime import datetime, timedelta +from datetime import timedelta import voluptuous as vol @@ -23,6 +23,8 @@ CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' DEPENDENCIES = ['ring', 'ffmpeg'] +FORCE_REFRESH_INTERVAL = timedelta(minutes=45) + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=90) @@ -63,8 +65,8 @@ class RingCam(Camera): self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) self._last_video_id = self._camera.last_recording_id self._video_url = self._camera.recording_url(self._last_video_id) - self._expires_at = None - self._utcnow = None + self._utcnow = dt_util.utcnow() + self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow @property def name(self): @@ -123,19 +125,19 @@ class RingCam(Camera): def update(self): """Update camera entity and refresh attributes.""" - # extract the video expiration from URL - x_amz_expires = int(self._video_url.split('&')[0].split('=')[-1]) - x_amz_date = self._video_url.split('&')[1].split('=')[-1] + _LOGGER.debug("Checking if Ring DoorBell needs to refresh video_url") + self._camera.update() self._utcnow = dt_util.utcnow() - self._expires_at = \ - timedelta(seconds=x_amz_expires) + \ - dt_util.as_utc(datetime.strptime(x_amz_date, "%Y%m%dT%H%M%SZ")) - if self._last_video_id != self._camera.last_recording_id: - _LOGGER.debug("Updated Ring DoorBell last_video_id") + last_recording_id = self._camera.last_recording_id + + if self._last_video_id != last_recording_id or \ + self._utcnow >= self._expires_at: + + _LOGGER.info("Ring DoorBell properties refreshed") + + # update attributes if new video or if URL has expired self._last_video_id = self._camera.last_recording_id - - if self._utcnow >= self._expires_at: - _LOGGER.debug("Updated Ring DoorBell video_url") self._video_url = self._camera.recording_url(self._last_video_id) + self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow diff --git a/homeassistant/components/sensor/ring.py b/homeassistant/components/sensor/ring.py index 6c8794d096f..cae7690103d 100644 --- a/homeassistant/components/sensor/ring.py +++ b/homeassistant/components/sensor/ring.py @@ -34,7 +34,7 @@ SENSOR_TYPES = { 'Last Activity', ['doorbell', 'stickup_cams'], None, 'history', None], 'last_ding': [ - 'Last Ding', ['doorbell', 'stickup_cams'], None, 'history', 'ding'], + 'Last Ding', ['doorbell'], None, 'history', 'ding'], 'last_motion': [ 'Last Motion', ['doorbell', 'stickup_cams'], None, From 9297a9cbb4d6964ed1aa33cb6ffd6607cc890f60 Mon Sep 17 00:00:00 2001 From: TopdRob Date: Thu, 9 Nov 2017 06:09:19 +0100 Subject: [PATCH 050/137] Upgrade apns2 to 0.3.0 (#10347) --- homeassistant/components/notify/apns.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 250ef5c50c8..f6f7cc71f14 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_NAME, CONF_PLATFORM import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template as template_helper -REQUIREMENTS = ['apns2==0.1.1'] +REQUIREMENTS = ['apns2==0.3.0'] APNS_DEVICES = 'apns.yaml' CONF_CERTFILE = 'cert_file' diff --git a/requirements_all.txt b/requirements_all.txt index a85fcbea2f9..19f354f7b36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -95,7 +95,7 @@ anthemav==1.1.8 apcaccess==0.0.13 # homeassistant.components.notify.apns -apns2==0.1.1 +apns2==0.3.0 # homeassistant.components.asterisk_mbox asterisk_mbox==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14dea3f25cf..9f45cc4516e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -34,7 +34,7 @@ aioautomatic==0.6.4 aiohttp_cors==0.5.3 # homeassistant.components.notify.apns -apns2==0.1.1 +apns2==0.3.0 # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.1.1 From ee265394a686fdc3921739867258aac4b570f7cc Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Thu, 9 Nov 2017 11:49:19 +0100 Subject: [PATCH 051/137] Improvement of KNX climate component (#10388) * Added myself to codeowners * Improved climate support with setpoint shift for KNX. (https://github.com/XKNX/xknx/issues/48) * requirements_all.txt * typo * flake * Changes requested by @pvizeli --- CODEOWNERS | 4 ++ homeassistant/components/climate/knx.py | 55 +++++++++++++++---------- homeassistant/components/knx.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8fd5d0826c1..82ae451e59c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -64,6 +64,10 @@ homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/*/rfxtrx.py @danielhiversen +homeassistant/components/velux.py @Julius2342 +homeassistant/components/*/velux.py @Julius2342 +homeassistant/components/knx.py @Julius2342 +homeassistant/components/*/knx.py @Julius2342 homeassistant/components/tesla.py @zabuldon homeassistant/components/*/tesla.py @zabuldon homeassistant/components/*/tradfri.py @ggravlingen diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 784d8a4ed28..69c144985d6 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -13,9 +13,11 @@ from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -CONF_SETPOINT_ADDRESS = 'setpoint_address' CONF_SETPOINT_SHIFT_ADDRESS = 'setpoint_shift_address' CONF_SETPOINT_SHIFT_STATE_ADDRESS = 'setpoint_shift_state_address' +CONF_SETPOINT_SHIFT_STEP = 'setpoint_shift_step' +CONF_SETPOINT_SHIFT_MAX = 'setpoint_shift_max' +CONF_SETPOINT_SHIFT_MIN = 'setpoint_shift_min' CONF_TEMPERATURE_ADDRESS = 'temperature_address' CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' @@ -28,15 +30,24 @@ CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' DEFAULT_NAME = 'KNX Climate' +DEFAULT_SETPOINT_SHIFT_STEP = 0.5 +DEFAULT_SETPOINT_SHIFT_MAX = 6 +DEFAULT_SETPOINT_SHIFT_MIN = -6 DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string, vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string, + vol.Optional(CONF_SETPOINT_SHIFT_STEP, + default=DEFAULT_SETPOINT_SHIFT_STEP): vol.All( + float, vol.Range(min=0, max=2)), + vol.Optional(CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX): + vol.All(int, vol.Range(min=-32, max=0)), + vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN): + vol.All(int, vol.Range(min=0, max=32)), vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, @@ -77,6 +88,7 @@ def async_add_devices_discovery(hass, discovery_info, async_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( hass.data[DATA_KNX].xknx, name=config.get(CONF_NAME), @@ -84,12 +96,16 @@ def async_add_devices_config(hass, config, async_add_devices): CONF_TEMPERATURE_ADDRESS), group_address_target_temperature=config.get( CONF_TARGET_TEMPERATURE_ADDRESS), - group_address_setpoint=config.get( - CONF_SETPOINT_ADDRESS), group_address_setpoint_shift=config.get( CONF_SETPOINT_SHIFT_ADDRESS), group_address_setpoint_shift_state=config.get( CONF_SETPOINT_SHIFT_STATE_ADDRESS), + setpoint_shift_step=config.get( + CONF_SETPOINT_SHIFT_STEP), + setpoint_shift_max=config.get( + CONF_SETPOINT_SHIFT_MAX), + setpoint_shift_min=config.get( + CONF_SETPOINT_SHIFT_MIN), group_address_operation_mode=config.get( CONF_OPERATION_MODE_ADDRESS), group_address_operation_mode_state=config.get( @@ -118,8 +134,6 @@ class KNXClimate(ClimateDevice): self.async_register_callbacks() self._unit_of_measurement = TEMP_CELSIUS - self._away = False # not yet supported - self._is_fan_on = False # not yet supported def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" @@ -150,28 +164,25 @@ class KNXClimate(ClimateDevice): """Return the current temperature.""" return self.device.temperature.value + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self.device.setpoint_shift_step + @property def target_temperature(self): """Return the temperature we try to reach.""" - return self.device.target_temperature_comfort + return self.device.target_temperature.value @property - def target_temperature_high(self): - """Return the highbound target temperature we try to reach.""" - if self.device.target_temperature_comfort: - return max( - self.device.target_temperature_comfort, - self.device.target_temperature.value) - return None + def min_temp(self): + """Return the minimum temperature.""" + return self.device.target_temperature_min @property - def target_temperature_low(self): - """Return the lowbound target temperature we try to reach.""" - if self.device.target_temperature_comfort: - return min( - self.device.target_temperature_comfort, - self.device.target_temperature.value) - return None + def max_temp(self): + """Return the maximum temperature.""" + return self.device.target_temperature_max @asyncio.coroutine def async_set_temperature(self, **kwargs): @@ -179,7 +190,7 @@ class KNXClimate(ClimateDevice): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - yield from self.device.set_target_temperature_comfort(temperature) + yield from self.device.set_target_temperature(temperature) yield from self.async_update_ha_state() @property diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index b86574c1d2e..3966b490f52 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -36,7 +36,7 @@ ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['xknx==0.7.16'] +REQUIREMENTS = ['xknx==0.7.18'] TUNNELING_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, diff --git a/requirements_all.txt b/requirements_all.txt index 19f354f7b36..6db5418fba2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1131,7 +1131,7 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.7.16 +xknx==0.7.18 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data From 62c1b542edf2ff24baf1749c57293af94c8413e5 Mon Sep 17 00:00:00 2001 From: Stefan Jonasson Date: Thu, 9 Nov 2017 15:03:35 +0100 Subject: [PATCH 052/137] Tellstick Duo acync callback fix (#10384) * Reverted commit 1c8f1796903d06786060c53b48f07733708853a1. This fixes issue: #10329 * convert callback to async * fix lint * cleanup * cleanup * cleanups * optimize initial handling * Update tellstick.py * Update tellstick.py * fix lint * fix lint * Update tellstick.py * Fixed code errors and lint problems. * fix bug * Reduce logic, migrate to dispatcher * Update tellstick.py * Update tellstick.py * fix lint * fix lint --- homeassistant/components/light/tellstick.py | 20 ++- homeassistant/components/switch/tellstick.py | 22 ++-- homeassistant/components/tellstick.py | 126 ++++++++----------- 3 files changed, 68 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 598cd22c986..1bf7d632af5 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -4,15 +4,13 @@ Support for Tellstick lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.tellstick/ """ -import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) from homeassistant.components.tellstick import ( DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, ATTR_DISCOVER_CONFIG, - DOMAIN, TellstickDevice) + DATA_TELLSTICK, TellstickDevice) -PLATFORM_SCHEMA = vol.Schema({vol.Required("platform"): DOMAIN}) SUPPORT_TELLSTICK = SUPPORT_BRIGHTNESS @@ -27,17 +25,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): signal_repetitions = discovery_info.get( ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS) - add_devices(TellstickLight(tellcore_id, hass.data['tellcore_registry'], - signal_repetitions) - for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]) + add_devices([TellstickLight(hass.data[DATA_TELLSTICK][tellcore_id], + signal_repetitions) + for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]], + True) class TellstickLight(TellstickDevice, Light): """Representation of a Tellstick light.""" - def __init__(self, tellcore_id, tellcore_registry, signal_repetitions): + def __init__(self, tellcore_device, signal_repetitions): """Initialize the Tellstick light.""" - super().__init__(tellcore_id, tellcore_registry, signal_repetitions) + super().__init__(tellcore_device, signal_repetitions) self._brightness = 255 @@ -57,9 +56,8 @@ class TellstickLight(TellstickDevice, Light): def _parse_tellcore_data(self, tellcore_data): """Turn the value received from tellcore into something useful.""" - if tellcore_data is not None: - brightness = int(tellcore_data) - return brightness + if tellcore_data: + return int(tellcore_data) # brightness return None def _update_model(self, new_state, data): diff --git a/homeassistant/components/switch/tellstick.py b/homeassistant/components/switch/tellstick.py index de7a3bf4545..ae19e77c2e5 100644 --- a/homeassistant/components/switch/tellstick.py +++ b/homeassistant/components/switch/tellstick.py @@ -4,16 +4,11 @@ Support for Tellstick switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.tellstick/ """ -import voluptuous as vol - -from homeassistant.components.tellstick import (DEFAULT_SIGNAL_REPETITIONS, - ATTR_DISCOVER_DEVICES, - ATTR_DISCOVER_CONFIG, - DOMAIN, TellstickDevice) +from homeassistant.components.tellstick import ( + DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, + ATTR_DISCOVER_CONFIG, DATA_TELLSTICK, TellstickDevice) from homeassistant.helpers.entity import ToggleEntity -PLATFORM_SCHEMA = vol.Schema({vol.Required("platform"): DOMAIN}) - # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -26,9 +21,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): signal_repetitions = discovery_info.get(ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS) - add_devices(TellstickSwitch(tellcore_id, hass.data['tellcore_registry'], - signal_repetitions) - for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]) + add_devices([TellstickSwitch(hass.data[DATA_TELLSTICK][tellcore_id], + signal_repetitions) + for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]], + True) class TellstickSwitch(TellstickDevice, ToggleEntity): @@ -36,11 +32,11 @@ class TellstickSwitch(TellstickDevice, ToggleEntity): def _parse_ha_data(self, kwargs): """Turn the value from HA into something useful.""" - return None + pass def _parse_tellcore_data(self, tellcore_data): """Turn the value received from tellcore into something useful.""" - return None + pass def _update_model(self, new_state, data): """Update the device entity state to match the arguments.""" diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index 85407ff4c7a..91a7c0c69e5 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -4,12 +4,14 @@ Tellstick Component. For more details about this component, please refer to the documentation at https://home-assistant.io/components/tellstick/ """ +import asyncio import logging import threading import voluptuous as vol from homeassistant.helpers import discovery +from homeassistant.core import callback from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) from homeassistant.helpers.entity import Entity @@ -26,6 +28,9 @@ CONF_SIGNAL_REPETITIONS = 'signal_repetitions' DEFAULT_SIGNAL_REPETITIONS = 1 DOMAIN = 'tellstick' +DATA_TELLSTICK = 'tellstick_device' +SIGNAL_TELLCORE_CALLBACK = 'tellstick_callback' + # Use a global tellstick domain lock to avoid getting Tellcore errors when # calling concurrently. TELLSTICK_LOCK = threading.RLock() @@ -62,7 +67,7 @@ def _discover(hass, config, component_name, found_tellcore_devices): def setup(hass, config): """Set up the Tellstick component.""" from tellcore.constants import TELLSTICK_DIM - from tellcore.telldus import QueuedCallbackDispatcher + from tellcore.telldus import AsyncioCallbackDispatcher from tellcore.telldus import TelldusCore from tellcorenet import TellCoreClient @@ -83,94 +88,57 @@ def setup(hass, config): try: tellcore_lib = TelldusCore( - callback_dispatcher=QueuedCallbackDispatcher()) + callback_dispatcher=AsyncioCallbackDispatcher(hass.loop)) except OSError: _LOGGER.exception("Could not initialize Tellstick") return False # Get all devices, switches and lights alike - all_tellcore_devices = tellcore_lib.devices() + tellcore_devices = tellcore_lib.devices() # Register devices - tellcore_registry = TellstickRegistry(hass, tellcore_lib) - tellcore_registry.register_tellcore_devices(all_tellcore_devices) - hass.data['tellcore_registry'] = tellcore_registry + hass.data[DATA_TELLSTICK] = {device.id: device for + device in tellcore_devices} # Discover the switches _discover(hass, config, 'switch', - [tellcore_device.id for tellcore_device in all_tellcore_devices - if not tellcore_device.methods(TELLSTICK_DIM)]) + [device.id for device in tellcore_devices + if not device.methods(TELLSTICK_DIM)]) # Discover the lights _discover(hass, config, 'light', - [tellcore_device.id for tellcore_device in all_tellcore_devices - if tellcore_device.methods(TELLSTICK_DIM)]) + [device.id for device in tellcore_devices + if device.methods(TELLSTICK_DIM)]) + + @callback + def async_handle_callback(tellcore_id, tellcore_command, + tellcore_data, cid): + """Handle the actual callback from Tellcore.""" + hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_TELLCORE_CALLBACK, tellcore_id, + tellcore_command, tellcore_data) + + # Register callback + callback_id = tellcore_lib.register_device_event( + async_handle_callback) + + def clean_up_callback(event): + """Unregister the callback bindings.""" + if callback_id is not None: + tellcore_lib.unregister_callback(callback_id) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, clean_up_callback) return True -class TellstickRegistry(object): - """Handle everything around Tellstick callbacks. - - Keeps a map device ids to the tellcore device object, and - another to the HA device objects (entities). - - Also responsible for registering / cleanup of callbacks, and for - dispatching the callbacks to the corresponding HA device object. - - All device specific logic should be elsewhere (Entities). - """ - - def __init__(self, hass, tellcore_lib): - """Initialize the Tellstick mappings and callbacks.""" - # used when map callback device id to ha entities. - self._id_to_ha_device_map = {} - self._id_to_tellcore_device_map = {} - self._setup_tellcore_callback(hass, tellcore_lib) - - def _tellcore_event_callback(self, tellcore_id, tellcore_command, - tellcore_data, cid): - """Handle the actual callback from Tellcore.""" - ha_device = self._id_to_ha_device_map.get(tellcore_id, None) - if ha_device is not None: - # Pass it on to the HA device object - ha_device.update_from_callback(tellcore_command, tellcore_data) - - def _setup_tellcore_callback(self, hass, tellcore_lib): - """Register the callback handler.""" - callback_id = tellcore_lib.register_device_event( - self._tellcore_event_callback) - - def clean_up_callback(event): - """Unregister the callback bindings.""" - if callback_id is not None: - tellcore_lib.unregister_callback(callback_id) - _LOGGER.debug("Tellstick callback unregistered") - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, clean_up_callback) - - def register_ha_device(self, tellcore_id, ha_device): - """Register a new HA device to receive callback updates.""" - self._id_to_ha_device_map[tellcore_id] = ha_device - - def register_tellcore_devices(self, tellcore_devices): - """Register a list of devices.""" - self._id_to_tellcore_device_map.update( - {tellcore_device.id: tellcore_device for tellcore_device - in tellcore_devices}) - - def get_tellcore_device(self, tellcore_id): - """Return a device by tellcore_id.""" - return self._id_to_tellcore_device_map.get(tellcore_id, None) - - class TellstickDevice(Entity): """Representation of a Tellstick device. Contains the common logic for all Tellstick devices. """ - def __init__(self, tellcore_id, tellcore_registry, signal_repetitions): + def __init__(self, tellcore_device, signal_repetitions): """Init the Tellstick device.""" self._signal_repetitions = signal_repetitions self._state = None @@ -179,13 +147,16 @@ class TellstickDevice(Entity): self._repeats_left = 0 # Look up our corresponding tellcore device - self._tellcore_device = tellcore_registry.get_tellcore_device( - tellcore_id) - self._name = self._tellcore_device.name - # Query tellcore for the current state - self._update_from_tellcore() - # Add ourselves to the mapping for callbacks - tellcore_registry.register_ha_device(tellcore_id, self) + self._tellcore_device = tellcore_device + self._name = tellcore_device.name + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_TELLCORE_CALLBACK, + self.update_from_callback + ) @property def should_poll(self): @@ -275,15 +246,19 @@ class TellstickDevice(Entity): self._update_model(tellcore_command != TELLSTICK_TURNOFF, self._parse_tellcore_data(tellcore_data)) - def update_from_callback(self, tellcore_command, tellcore_data): + def update_from_callback(self, tellcore_id, tellcore_command, + tellcore_data): """Handle updates from the tellcore callback.""" + if tellcore_id != self._tellcore_device.id: + return + self._update_model_from_command(tellcore_command, tellcore_data) self.schedule_update_ha_state() # This is a benign race on _repeats_left -- it's checked with the lock # in _send_repeated_command. if self._repeats_left > 0: - self.hass.async_add_job(self._send_repeated_command) + self._send_repeated_command() def _update_from_tellcore(self): """Read the current state of the device from the tellcore library.""" @@ -303,4 +278,3 @@ class TellstickDevice(Entity): def update(self): """Poll the current state of the device.""" self._update_from_tellcore() - self.schedule_update_ha_state() From 68986e914345b0b0c2da3c14925ff13e69701aee Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 9 Nov 2017 11:54:45 -0500 Subject: [PATCH 053/137] Updated gc100 package requirement to 1.0.3a (#10484) * Updated gc100 package requirement to 1.0.3a * Update requirements_all.txt --- homeassistant/components/gc100.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gc100.py b/homeassistant/components/gc100.py index 7c772e345ae..bc627d44417 100644 --- a/homeassistant/components/gc100.py +++ b/homeassistant/components/gc100.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-gc100==1.0.1a'] +REQUIREMENTS = ['python-gc100==1.0.3a'] _LOGGER = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def setup(hass, base_config): gc_device = gc100.GC100SocketClient(host, port) - def cleanup_gc100(): + def cleanup_gc100(event): """Stuff to do before stopping.""" gc_device.quit() diff --git a/requirements_all.txt b/requirements_all.txt index 6db5418fba2..cb20d998710 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -816,7 +816,7 @@ python-etherscan-api==0.0.1 python-forecastio==1.3.5 # homeassistant.components.gc100 -python-gc100==1.0.1a +python-gc100==1.0.3a # homeassistant.components.sensor.hp_ilo python-hpilo==3.9 From dd16b7cac39d45ba413938c9032ff483c4839b99 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 9 Nov 2017 17:57:41 +0100 Subject: [PATCH 054/137] Remove lag from Harmony remote platform (#10218) * Simplify kwargs handling * Move Harmony remote to a persistent connection with push feedback * Make default delay_secs configurable on the harmony platform * Remove lint * Fix delay_secs with discovery * Temporary location for updated pyharmony * Remove lint * Update pyharmony to 1.0.17 * Remove lint * Return an Optional marker * Update pyharmony to 1.0.18 --- homeassistant/components/remote/__init__.py | 20 +--- homeassistant/components/remote/harmony.py | 120 ++++++++++---------- requirements_all.txt | 2 +- 3 files changed, 67 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 41dbec851b5..3f1086c46c7 100755 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -61,8 +61,7 @@ REMOTE_SERVICE_SEND_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ vol.Optional(ATTR_DEVICE): cv.string, vol.Optional( ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS): cv.positive_int, - vol.Optional( - ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float) + vol.Optional(ATTR_DELAY_SECS): vol.Coerce(float), }) @@ -141,25 +140,18 @@ def async_setup(hass, config): def async_handle_remote_service(service): """Handle calls to the remote services.""" target_remotes = component.async_extract_from_service(service) - - activity_id = service.data.get(ATTR_ACTIVITY) - device = service.data.get(ATTR_DEVICE) - command = service.data.get(ATTR_COMMAND) - num_repeats = service.data.get(ATTR_NUM_REPEATS) - delay_secs = service.data.get(ATTR_DELAY_SECS) + kwargs = service.data.copy() update_tasks = [] for remote in target_remotes: if service.service == SERVICE_TURN_ON: - yield from remote.async_turn_on(activity=activity_id) + yield from remote.async_turn_on(**kwargs) elif service.service == SERVICE_TOGGLE: - yield from remote.async_toggle(activity=activity_id) + yield from remote.async_toggle(**kwargs) elif service.service == SERVICE_SEND_COMMAND: - yield from remote.async_send_command( - device=device, command=command, - num_repeats=num_repeats, delay_secs=delay_secs) + yield from remote.async_send_command(**kwargs) else: - yield from remote.async_turn_off(activity=activity_id) + yield from remote.async_turn_off(**kwargs) if not remote.should_poll: continue diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index b25741207de..7a398def5f9 100755 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -5,22 +5,23 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/remote.harmony/ """ import logging +import asyncio from os import path -import urllib.parse +import time import voluptuous as vol import homeassistant.components.remote as remote import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID) + CONF_NAME, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP) from homeassistant.components.remote import ( PLATFORM_SCHEMA, DOMAIN, ATTR_DEVICE, ATTR_ACTIVITY, ATTR_NUM_REPEATS, - ATTR_DELAY_SECS) + ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) from homeassistant.util import slugify from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyharmony==1.0.16'] +REQUIREMENTS = ['pyharmony==1.0.18'] _LOGGER = logging.getLogger(__name__) @@ -35,6 +36,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(ATTR_ACTIVITY, default=None): cv.string, + vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): + vol.Coerce(float), }) HARMONY_SYNC_SCHEMA = vol.Schema({ @@ -44,8 +47,6 @@ HARMONY_SYNC_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Harmony platform.""" - import pyharmony - host = None activity = None @@ -61,6 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = DEFAULT_PORT if override: activity = override.get(ATTR_ACTIVITY) + delay_secs = override.get(ATTR_DELAY_SECS) port = override.get(CONF_PORT, DEFAULT_PORT) host = ( @@ -79,6 +81,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_PORT), ) activity = config.get(ATTR_ACTIVITY) + delay_secs = config.get(ATTR_DELAY_SECS) else: hass.data[CONF_DEVICE_CACHE].append(config) return @@ -86,26 +89,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name, address, port = host _LOGGER.info("Loading Harmony Platform: %s at %s:%s, startup activity: %s", name, address, port, activity) - try: - _LOGGER.debug("Calling pyharmony.ha_get_token for remote at: %s:%s", - address, port) - token = urllib.parse.quote_plus(pyharmony.ha_get_token(address, port)) - _LOGGER.debug("Received token: %s", token) - except ValueError as err: - _LOGGER.warning("%s for remote: %s", err.args[0], name) - return False harmony_conf_file = hass.config.path( '{}{}{}'.format('harmony_', slugify(name), '.conf')) - device = HarmonyRemote( - name, address, port, - activity, harmony_conf_file, token) - - DEVICES.append(device) - - add_devices([device]) - register_services(hass) - return True + try: + device = HarmonyRemote( + name, address, port, activity, harmony_conf_file, delay_secs) + DEVICES.append(device) + add_devices([device]) + register_services(hass) + except ValueError: + _LOGGER.warning("Failed to initialize remote: %s", name) def register_services(hass): @@ -140,7 +134,7 @@ def _sync_service(service): class HarmonyRemote(remote.RemoteDevice): """Remote representation used to control a Harmony device.""" - def __init__(self, name, host, port, activity, out_path, token): + def __init__(self, name, host, port, activity, out_path, delay_secs): """Initialize HarmonyRemote class.""" import pyharmony from pathlib import Path @@ -152,20 +146,35 @@ class HarmonyRemote(remote.RemoteDevice): self._state = None self._current_activity = None self._default_activity = activity - self._token = token + self._client = pyharmony.get_client(host, port, self.new_activity) self._config_path = out_path - _LOGGER.debug("Retrieving harmony config using token: %s", token) - self._config = pyharmony.ha_get_config(self._token, host, port) + self._config = self._client.get_config() if not Path(self._config_path).is_file(): _LOGGER.debug("Writing harmony configuration to file: %s", out_path) pyharmony.ha_write_config_file(self._config, self._config_path) + self._delay_secs = delay_secs + + @asyncio.coroutine + def async_added_to_hass(self): + """Complete the initialization.""" + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + lambda event: self._client.disconnect(wait=True)) + + # Poll for initial state + self.new_activity(self._client.get_current_activity()) @property def name(self): """Return the Harmony device's name.""" return self._name + @property + def should_poll(self): + """Return the fact that we should not be polled.""" + return False + @property def device_state_attributes(self): """Add platform specific attributes.""" @@ -176,60 +185,51 @@ class HarmonyRemote(remote.RemoteDevice): """Return False if PowerOff is the current activity, otherwise True.""" return self._current_activity not in [None, 'PowerOff'] - def update(self): - """Return current activity.""" + def new_activity(self, activity_id): + """Callback for updating the current activity.""" import pyharmony - name = self._name - _LOGGER.debug("Polling %s for current activity", name) - state = pyharmony.ha_get_current_activity( - self._token, self._config, self.host, self._port) - _LOGGER.debug("%s current activity reported as: %s", name, state) - self._current_activity = state - self._state = bool(state != 'PowerOff') + activity_name = pyharmony.activity_name(self._config, activity_id) + _LOGGER.debug("%s activity reported as: %s", self._name, activity_name) + self._current_activity = activity_name + self._state = bool(self._current_activity != 'PowerOff') + self.schedule_update_ha_state() def turn_on(self, **kwargs): """Start an activity from the Harmony device.""" import pyharmony - if kwargs[ATTR_ACTIVITY]: - activity = kwargs[ATTR_ACTIVITY] - else: - activity = self._default_activity + activity = kwargs.get(ATTR_ACTIVITY, self._default_activity) if activity: - pyharmony.ha_start_activity( - self._token, self.host, self._port, self._config, activity) + activity_id = pyharmony.activity_id(self._config, activity) + self._client.start_activity(activity_id) self._state = True else: _LOGGER.error("No activity specified with turn_on service") def turn_off(self, **kwargs): """Start the PowerOff activity.""" - import pyharmony - pyharmony.ha_power_off(self._token, self.host, self._port) + self._client.power_off() - def send_command(self, command, **kwargs): - """Send a set of commands to one device.""" - import pyharmony - device = kwargs.pop(ATTR_DEVICE, None) + def send_command(self, commands, **kwargs): + """Send a list of commands to one device.""" + device = kwargs.get(ATTR_DEVICE) if device is None: _LOGGER.error("Missing required argument: device") return - params = {} - num_repeats = kwargs.pop(ATTR_NUM_REPEATS, None) - if num_repeats is not None: - params['repeat_num'] = num_repeats - delay_secs = kwargs.pop(ATTR_DELAY_SECS, None) - if delay_secs is not None: - params['delay_secs'] = delay_secs - pyharmony.ha_send_commands( - self._token, self.host, self._port, device, command, **params) + + num_repeats = kwargs.get(ATTR_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs) + + for _ in range(num_repeats): + for command in commands: + self._client.send_command(device, command) + time.sleep(delay_secs) def sync(self): """Sync the Harmony device with the web service.""" import pyharmony _LOGGER.debug("Syncing hub with Harmony servers") - pyharmony.ha_sync(self._token, self.host, self._port) - self._config = pyharmony.ha_get_config( - self._token, self.host, self._port) + self._client.sync() + self._config = self._client.get_config() _LOGGER.debug("Writing hub config to file: %s", self._config_path) pyharmony.ha_write_config_file(self._config, self._config_path) diff --git a/requirements_all.txt b/requirements_all.txt index cb20d998710..931d371b339 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -666,7 +666,7 @@ pyflexit==0.3 pyfttt==0.3 # homeassistant.components.remote.harmony -pyharmony==1.0.16 +pyharmony==1.0.18 # homeassistant.components.binary_sensor.hikvision pyhik==0.1.4 From 37eae7fb8a1c3c382833f383f303578daf37adcc Mon Sep 17 00:00:00 2001 From: cgtobi Date: Thu, 9 Nov 2017 20:17:01 +0100 Subject: [PATCH 055/137] Improve error handling. (#10482) * Improve error handling. * Fix import of core requirements. * cleanup --- homeassistant/components/influxdb.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 1c261d5ec3e..b41deb5e5e3 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -5,9 +5,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/influxdb/ """ import logging - import re +import requests.exceptions import voluptuous as vol from homeassistant.const import ( @@ -123,10 +123,12 @@ def setup(hass, config): try: influx = InfluxDBClient(**kwargs) influx.query("SHOW SERIES LIMIT 1;", database=conf[CONF_DB_NAME]) - except exceptions.InfluxDBClientError as exc: + except (exceptions.InfluxDBClientError, + requests.exceptions.ConnectionError) as exc: _LOGGER.error("Database host is not accessible due to '%s', please " - "check your entries in the configuration file and that " - "the database exists and is READ/WRITE.", exc) + "check your entries in the configuration file (host, " + "port, etc.) and verify that the database exists and is " + "READ/WRITE.", exc) return False def influx_event_listener(event): From 8878eccb7bf330ff86015963e1f12028b0f4a1c9 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 9 Nov 2017 20:17:31 +0100 Subject: [PATCH 056/137] Upgrade psutil to 5.4.1 (#10490) --- 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 0c9a21447a8..324d3029c99 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.4.0'] +REQUIREMENTS = ['psutil==5.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 931d371b339..720ef52be1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -558,7 +558,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.4.0 +psutil==5.4.1 # homeassistant.components.wink pubnubsub-handler==1.0.2 From 8e1a73dd0fadc32ee34427292a53e0fcf8478c86 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 9 Nov 2017 20:18:29 +0100 Subject: [PATCH 057/137] Upgrade youtube_dl to 2017.11.06 (#10491) --- 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 9cee62c39f7..3b75c4494d8 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -16,7 +16,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.10.29'] +REQUIREMENTS = ['youtube_dl==2017.11.06'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 720ef52be1c..4b354a43225 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1153,7 +1153,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.10.29 +youtube_dl==2017.11.06 # homeassistant.components.light.zengge zengge==0.2 From 143d9492b26bfb3961dfc9193e0deaf37bd21b20 Mon Sep 17 00:00:00 2001 From: sander76 Date: Thu, 9 Nov 2017 21:17:23 +0100 Subject: [PATCH 058/137] Fix for telegram polling. (added pausing when error occurs) (#10214) * Fix for telegram polling. (added pausing when error occurs) * fix pylint error. invalid variable name ( Exception as _e)). Don't understand why as removing the underscore fails with my local pylint.. * fixing too short variable name. * moved logic to `check_incoming` * fix line too long error. * Simplify --- .../components/telegram_bot/polling.py | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index d94bbddffab..0ce11441843 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -23,6 +23,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = TELEGRAM_PLATFORM_SCHEMA +RETRY_SLEEP = 10 + + +class WrongHttpStatus(Exception): + """Thrown when a wrong http status is received.""" + + pass @asyncio.coroutine @@ -74,8 +81,6 @@ class TelegramPoll(BaseTelegramBotEntity): def get_updates(self, offset): """Bypass the default long polling method to enable asyncio.""" resp = None - _json = {'result': [], 'ok': True} # Empty result. - if offset: self.post_data['offset'] = offset try: @@ -86,40 +91,38 @@ class TelegramPoll(BaseTelegramBotEntity): ) if resp.status == 200: _json = yield from resp.json() + return _json else: - _LOGGER.error("Error %s on %s", resp.status, self.update_url) - - except ValueError: - _LOGGER.error("Error parsing Json message") - except (asyncio.TimeoutError, ClientError): - _LOGGER.error("Client connection error") + raise WrongHttpStatus('wrong status %s', resp.status) finally: if resp is not None: yield from resp.release() - return _json - - @asyncio.coroutine - def handle(self): - """Receiving and processing incoming messages.""" - _updates = yield from self.get_updates(self.update_id) - _updates = _updates.get('result') - if _updates is None: - _LOGGER.error("Incorrect result received.") - else: - for update in _updates: - self.update_id = update['update_id'] + 1 - self.process_message(update) - @asyncio.coroutine def check_incoming(self): - """Loop which continuously checks for incoming telegram messages.""" + """Continuously check for incoming telegram messages.""" try: while True: - # Each handle call sends a long polling post request - # to the telegram server. If no incoming message it will return - # an empty list. Calling self.handle() without any delay or - # timeout will for this reason not really stress the processor. - yield from self.handle() + try: + _updates = yield from self.get_updates(self.update_id) + except (WrongHttpStatus, ClientError) as err: + # WrongHttpStatus: Non-200 status code. + # Occurs at times (mainly 502) and recovers + # automatically. Pause for a while before retrying. + _LOGGER.error(err) + yield from asyncio.sleep(RETRY_SLEEP) + except (asyncio.TimeoutError, ValueError): + # Long polling timeout. Nothing serious. + # Json error. Just retry for the next message. + pass + else: + # no exception raised. update received data. + _updates = _updates.get('result') + if _updates is None: + _LOGGER.error("Incorrect result received.") + else: + for update in _updates: + self.update_id = update['update_id'] + 1 + self.process_message(update) except CancelledError: _LOGGER.debug("Stopping Telegram polling bot") From 9bfdff0be1830724b26a8441fc95343b571cb5f7 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 10 Nov 2017 09:49:30 +0000 Subject: [PATCH 059/137] add JSON processing capabilities to sensor_serial (#10476) * add JSON processing capabilities * format fixes * format fixes * Fix according to @fabaff comment * reverting last commit to a more sane approach * docstring... * still docstring... * passed script/lint * downgrade exception JSONDecodeError was only introduced in Python3.5 Since we are still supporting 3.4 ValueError is the parent class of JSONDecodeError --- homeassistant/components/sensor/serial.py | 35 ++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/serial.py b/homeassistant/components/sensor/serial.py index 6f01a8f856b..7bed4b25011 100644 --- a/homeassistant/components/sensor/serial.py +++ b/homeassistant/components/sensor/serial.py @@ -6,12 +6,14 @@ https://home-assistant.io/components/sensor.serial/ """ import asyncio import logging +import json import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['pyserial-asyncio==0.4'] @@ -29,6 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }) @@ -39,7 +42,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): port = config.get(CONF_SERIAL_PORT) baudrate = config.get(CONF_BAUDRATE) - sensor = SerialSensor(name, port, baudrate) + value_template = config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = hass + + sensor = SerialSensor(name, port, baudrate, value_template) hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, sensor.stop_serial_read()) @@ -49,13 +56,15 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class SerialSensor(Entity): """Representation of a Serial sensor.""" - def __init__(self, name, port, baudrate): + def __init__(self, name, port, baudrate, value_template): """Initialize the Serial sensor.""" self._name = name self._state = None self._port = port self._baudrate = baudrate self._serial_loop_task = None + self._template = value_template + self._attributes = [] @asyncio.coroutine def async_added_to_hass(self): @@ -71,7 +80,20 @@ class SerialSensor(Entity): url=device, baudrate=rate, **kwargs) while True: line = yield from reader.readline() - self._state = line.decode('utf-8').strip() + line = line.decode('utf-8').strip() + + try: + data = json.loads(line) + if isinstance(data, dict): + self._attributes = data + except ValueError: + pass + + if self._template is not None: + line = self._template.async_render_with_possible_json_value( + line) + + self._state = line self.async_schedule_update_ha_state() @asyncio.coroutine @@ -90,6 +112,11 @@ class SerialSensor(Entity): """No polling needed.""" return False + @property + def state_attributes(self): + """Return the attributes of the entity (if any JSON present).""" + return self._attributes + @property def state(self): """Return the state of the sensor.""" From e7dc96397ced6662f2a581d293d7cc6a8799d88a Mon Sep 17 00:00:00 2001 From: Matthew Donoughe Date: Fri, 10 Nov 2017 06:17:25 -0500 Subject: [PATCH 060/137] upgrade to new pylutron_caseta with TLS (#10286) * upgrade to new pylutron with TLS * rename configuration options * change more methods to coroutines * use async_add_devices --- .../components/cover/lutron_caseta.py | 18 ++++++--- .../components/light/lutron_caseta.py | 15 +++++--- homeassistant/components/lutron_caseta.py | 38 ++++++++++++------- .../components/scene/lutron_caseta.py | 9 +++-- .../components/switch/lutron_caseta.py | 15 +++++--- requirements_all.txt | 2 +- 6 files changed, 63 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 31e4f1e3cf2..6ad9b093ed8 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -4,6 +4,7 @@ Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.cover import ( @@ -18,7 +19,8 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument -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 Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -27,7 +29,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaCover(LutronCasetaDevice, CoverDevice): @@ -48,21 +50,25 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): """Return the current position of cover.""" return self._state['current_state'] - def close_cover(self, **kwargs): + @asyncio.coroutine + def async_close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self, **kwargs): + @asyncio.coroutine + def async_open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, **kwargs): + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] self._smartbridge.set_value(self._device_id, position) - def update(self): + @asyncio.coroutine + def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index c11b3da6f75..e4e1baf6c58 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -4,6 +4,7 @@ Support for Lutron Caseta lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.light import ( @@ -19,7 +20,8 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument -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 Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -28,7 +30,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaLight(light_device, bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaLight(LutronCasetaDevice, Light): @@ -44,7 +46,8 @@ class LutronCasetaLight(LutronCasetaDevice, Light): """Return the brightness of the light.""" return to_hass_level(self._state["current_state"]) - def turn_on(self, **kwargs): + @asyncio.coroutine + def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] @@ -53,7 +56,8 @@ class LutronCasetaLight(LutronCasetaDevice, Light): self._smartbridge.set_value(self._device_id, to_lutron_level(brightness)) - def turn_off(self, **kwargs): + @asyncio.coroutine + def async_turn_off(self, **kwargs): """Turn the light off.""" self._smartbridge.set_value(self._device_id, 0) @@ -62,7 +66,8 @@ class LutronCasetaLight(LutronCasetaDevice, Light): """Return true if device is on.""" return self._state["current_state"] > 0 - def update(self): + @asyncio.coroutine + def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 8660546c910..63f0315f35c 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.2.8'] +REQUIREMENTS = ['pylutron-caseta==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -22,9 +22,16 @@ LUTRON_CASETA_SMARTBRIDGE = 'lutron_smartbridge' DOMAIN = 'lutron_caseta' +CONF_KEYFILE = 'keyfile' +CONF_CERTFILE = 'certfile' +CONF_CA_CERTS = 'ca_certs' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_KEYFILE): cv.string, + vol.Required(CONF_CERTFILE): cv.string, + vol.Required(CONF_CA_CERTS): cv.string }) }, extra=vol.ALLOW_EXTRA) @@ -33,14 +40,21 @@ LUTRON_CASETA_COMPONENTS = [ ] -def setup(hass, base_config): +@asyncio.coroutine +def async_setup(hass, base_config): """Set up the Lutron component.""" from pylutron_caseta.smartbridge import Smartbridge config = base_config.get(DOMAIN) - hass.data[LUTRON_CASETA_SMARTBRIDGE] = Smartbridge( - hostname=config[CONF_HOST] - ) + keyfile = hass.config.path(config[CONF_KEYFILE]) + certfile = hass.config.path(config[CONF_CERTFILE]) + ca_certs = hass.config.path(config[CONF_CA_CERTS]) + bridge = Smartbridge.create_tls(hostname=config[CONF_HOST], + keyfile=keyfile, + certfile=certfile, + ca_certs=ca_certs) + hass.data[LUTRON_CASETA_SMARTBRIDGE] = bridge + yield from bridge.connect() if not hass.data[LUTRON_CASETA_SMARTBRIDGE].is_connected(): _LOGGER.error("Unable to connect to Lutron smartbridge at %s", config[CONF_HOST]) @@ -49,7 +63,8 @@ def setup(hass, base_config): _LOGGER.info("Connected to Lutron smartbridge at %s", config[CONF_HOST]) for component in LUTRON_CASETA_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) + hass.async_add_job(discovery.async_load_platform(hass, component, + DOMAIN, {}, config)) return True @@ -73,13 +88,8 @@ class LutronCasetaDevice(Entity): @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - self.hass.async_add_job( - self._smartbridge.add_subscriber, self._device_id, - self._update_callback - ) - - def _update_callback(self): - self.schedule_update_ha_state() + self._smartbridge.add_subscriber(self._device_id, + self.async_schedule_update_ha_state) @property def name(self): diff --git a/homeassistant/components/scene/lutron_caseta.py b/homeassistant/components/scene/lutron_caseta.py index 066be8c9d75..53df0da7617 100644 --- a/homeassistant/components/scene/lutron_caseta.py +++ b/homeassistant/components/scene/lutron_caseta.py @@ -4,6 +4,7 @@ Support for Lutron Caseta scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.lutron_caseta import LUTRON_CASETA_SMARTBRIDGE @@ -14,7 +15,8 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['lutron_caseta'] -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 Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -23,7 +25,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaScene(scenes[scene], bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaScene(Scene): @@ -50,6 +52,7 @@ class LutronCasetaScene(Scene): """There is no way of detecting if a scene is active (yet).""" return False - def activate(self, **kwargs): + @asyncio.coroutine + def async_activate(self, **kwargs): """Activate the scene.""" self._bridge.activate_scene(self._scene_id) diff --git a/homeassistant/components/switch/lutron_caseta.py b/homeassistant/components/switch/lutron_caseta.py index daaba68dc5e..da36c76f41d 100644 --- a/homeassistant/components/switch/lutron_caseta.py +++ b/homeassistant/components/switch/lutron_caseta.py @@ -4,6 +4,7 @@ Support for Lutron Caseta switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sitch.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.lutron_caseta import ( @@ -16,7 +17,8 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument -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 Lutron switch.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -26,18 +28,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaLight(switch_device, bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) return True class LutronCasetaLight(LutronCasetaDevice, SwitchDevice): """Representation of a Lutron Caseta switch.""" - def turn_on(self, **kwargs): + @asyncio.coroutine + def async_turn_on(self, **kwargs): """Turn the switch on.""" self._smartbridge.turn_on(self._device_id) - def turn_off(self, **kwargs): + @asyncio.coroutine + def async_turn_off(self, **kwargs): """Turn the switch off.""" self._smartbridge.turn_off(self._device_id) @@ -46,7 +50,8 @@ class LutronCasetaLight(LutronCasetaDevice, SwitchDevice): """Return true if device is on.""" return self._state["current_state"] > 0 - def update(self): + @asyncio.coroutine + def async_update(self): """Update when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/requirements_all.txt b/requirements_all.txt index 4b354a43225..dec8f96f39a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -712,7 +712,7 @@ pylitejet==0.1 pyloopenergy==0.0.17 # homeassistant.components.lutron_caseta -pylutron-caseta==0.2.8 +pylutron-caseta==0.3.0 # homeassistant.components.lutron pylutron==0.1.0 From 0490ca67d14cf43200b9fde3ae3805ba9aaa6238 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 10 Nov 2017 18:25:31 +0100 Subject: [PATCH 061/137] Bump dev to 0.58.0.dev0 (#10510) --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index bff2adae969..de3f60e825f 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 = 57 +MINOR_VERSION = 58 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 7d9d299d5a7112a1265d1cdda934dc68c1965110 Mon Sep 17 00:00:00 2001 From: Eric Hagan Date: Fri, 10 Nov 2017 11:29:21 -0600 Subject: [PATCH 062/137] OwnTracks Message Handling (#10489) * Improve handling and logging of unsupported owntracks message types Added generic handlers for message types that are valid but not supported by the HA component (lwt, beacon, etc.) and for message types which are invalid. Valid but not supported messages will now be logged as DEBUG. Invalid messages will be logged as WARNING. Supporting single "waypoint" messages in addition to the roll-up "waypoints" messages. Added tests around these features. * Style fixes --- .../components/device_tracker/owntracks.py | 71 +++++++++++------- .../device_tracker/test_owntracks.py | 73 ++++++++++++++++--- 2 files changed, 107 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 77241e1a8ab..0c869dd4b57 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -367,6 +367,29 @@ def async_handle_transition_message(hass, context, message): message['event']) +@asyncio.coroutine +def async_handle_waypoint(hass, name_base, waypoint): + """Handle a waypoint.""" + name = waypoint['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = waypoint['lat'] + lon = waypoint['lon'] + rad = waypoint['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + return + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + yield from zone.async_update_ha_state() + + +@HANDLERS.register('waypoint') @HANDLERS.register('waypoints') @asyncio.coroutine def async_handle_waypoints_message(hass, context, message): @@ -380,30 +403,17 @@ def async_handle_waypoints_message(hass, context, message): if user not in context.waypoint_whitelist: return - wayps = message['waypoints'] + if 'waypoints' in message: + wayps = message['waypoints'] + else: + wayps = [message] _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) name_base = ' '.join(_parse_topic(message['topic'])) for wayp in wayps: - name = wayp['desc'] - pretty_name = '{} - {}'.format(name_base, name) - lat = wayp['lat'] - lon = wayp['lon'] - rad = wayp['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - continue - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - yield from zone.async_update_ha_state() + yield from async_handle_waypoint(hass, name_base, wayp) @HANDLERS.register('encrypted') @@ -423,10 +433,22 @@ def async_handle_encrypted_message(hass, context, message): @HANDLERS.register('lwt') +@HANDLERS.register('configuration') +@HANDLERS.register('beacon') +@HANDLERS.register('cmd') +@HANDLERS.register('steps') +@HANDLERS.register('card') @asyncio.coroutine -def async_handle_lwt_message(hass, context, message): - """Handle an lwt message.""" - _LOGGER.debug('Not handling lwt message: %s', message) +def async_handle_not_impl_msg(hass, context, message): + """Handle valid but not implemented message types.""" + _LOGGER.debug('Not handling %s message: %s', message.get("_type"), message) + + +@asyncio.coroutine +def async_handle_unsupported_msg(hass, context, message): + """Handle an unsupported or invalid message type.""" + _LOGGER.warning('Received unsupported message type: %s.', + message.get('_type')) @asyncio.coroutine @@ -434,11 +456,6 @@ def async_handle_message(hass, context, message): """Handle an OwnTracks message.""" msgtype = message.get('_type') - handler = HANDLERS.get(msgtype) - - if handler is None: - _LOGGER.warning( - 'Received unsupported message type: %s.', msgtype) - return + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) yield from handler(hass, context, message) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index a06adcb286a..4f5efb9d09d 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -18,10 +18,13 @@ DEVICE = 'phone' LOCATION_TOPIC = 'owntracks/{}/{}'.format(USER, DEVICE) EVENT_TOPIC = 'owntracks/{}/{}/event'.format(USER, DEVICE) -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints'.format(USER, DEVICE) +WAYPOINTS_TOPIC = 'owntracks/{}/{}/waypoints'.format(USER, DEVICE) +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'.format(USER, DEVICE) USER_BLACKLIST = 'ram' -WAYPOINT_TOPIC_BLOCKED = 'owntracks/{}/{}/waypoints'.format( +WAYPOINTS_TOPIC_BLOCKED = 'owntracks/{}/{}/waypoints'.format( USER_BLACKLIST, DEVICE) +LWT_TOPIC = 'owntracks/{}/{}/lwt'.format(USER, DEVICE) +BAD_TOPIC = 'owntracks/{}/{}/unsupported'.format(USER, DEVICE) DEVICE_TRACKER_STATE = 'device_tracker.{}_{}'.format(USER, DEVICE) @@ -232,6 +235,15 @@ WAYPOINTS_UPDATED_MESSAGE = { ] } +WAYPOINT_MESSAGE = { + "_type": "waypoint", + "tst": 4, + "lat": 9, + "lon": 47, + "rad": 50, + "desc": "exp_wayp1" +} + WAYPOINT_ENTITY_NAMES = [ 'zone.greg_phone__exp_wayp1', 'zone.greg_phone__exp_wayp2', @@ -239,10 +251,26 @@ WAYPOINT_ENTITY_NAMES = [ 'zone.ram_phone__exp_wayp2', ] +LWT_MESSAGE = { + "_type": "lwt", + "tst": 1 +} + +BAD_MESSAGE = { + "_type": "unsupported", + "tst": 1 +} + BAD_JSON_PREFIX = '--$this is bad json#--' BAD_JSON_SUFFIX = '** and it ends here ^^' +# def raise_on_not_implemented(hass, context, message): +def raise_on_not_implemented(): + """Throw NotImplemented.""" + raise NotImplementedError("oopsie") + + class BaseMQTT(unittest.TestCase): """Base MQTT assert functions.""" @@ -1056,7 +1084,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_simple(self): """Test a simple import of list of waypoints.""" waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoints_message) + self.send_message(WAYPOINTS_TOPIC, waypoints_message) # Check if it made it into states wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) self.assertTrue(wayp is not None) @@ -1066,7 +1094,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_blacklist(self): """Test import of list of waypoints for blacklisted user.""" waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) + self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) self.assertTrue(wayp is None) @@ -1088,7 +1116,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): run_coroutine_threadsafe(owntracks.async_setup_scanner( self.hass, test_config, mock_see), self.hass.loop).result() waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) + self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) self.assertTrue(wayp is not None) @@ -1098,7 +1126,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_bad_json(self): """Test importing a bad JSON payload.""" waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoints_message, True) + self.send_message(WAYPOINTS_TOPIC, waypoints_message, True) # Check if it made it into states wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) self.assertTrue(wayp is None) @@ -1108,15 +1136,40 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_existing(self): """Test importing a zone that exists.""" waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoints_message) + self.send_message(WAYPOINTS_TOPIC, waypoints_message) # Get the first waypoint exported wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) # Send an update waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoints_message) + self.send_message(WAYPOINTS_TOPIC, waypoints_message) new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) self.assertTrue(wayp == new_wayp) + def test_single_waypoint_import(self): + """Test single waypoint message.""" + waypoint_message = WAYPOINT_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoint_message) + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + self.assertTrue(wayp is not None) + + def test_not_implemented_message(self): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_not_impl_msg', + return_value=mock_coro(False)) + patch_handler.start() + self.assertFalse(self.send_message(LWT_TOPIC, LWT_MESSAGE)) + patch_handler.stop() + + def test_unsupported_message(self): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_unsupported_msg', + return_value=mock_coro(False)) + patch_handler.start() + self.assertFalse(self.send_message(BAD_TOPIC, BAD_MESSAGE)) + patch_handler.stop() + def generate_ciphers(secret): """Generate test ciphers for the DEFAULT_LOCATION_MESSAGE.""" @@ -1143,7 +1196,7 @@ def generate_ciphers(secret): json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8")) ) ).decode("utf-8") - return (ctxt, mctxt) + return ctxt, mctxt TEST_SECRET_KEY = 's3cretkey' @@ -1172,7 +1225,7 @@ def mock_cipher(): if key != mkey: raise ValueError() return plaintext - return (len(TEST_SECRET_KEY), mock_decrypt) + return len(TEST_SECRET_KEY), mock_decrypt class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): From 16dd90ac78b6196dee5c7bd952b3760c3bfdd682 Mon Sep 17 00:00:00 2001 From: Kenny Millington Date: Fri, 10 Nov 2017 17:35:57 +0000 Subject: [PATCH 063/137] Add support for Alexa intent slot synonyms. (#10469) --- homeassistant/components/alexa/intent.py | 20 +++- tests/components/alexa/test_intent.py | 128 ++++++++++++++++++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index a0d0062414d..56887a8a701 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -138,10 +138,28 @@ class AlexaResponse(object): # Intent is None if request was a LaunchRequest or SessionEndedRequest if intent_info is not None: for key, value in intent_info.get('slots', {}).items(): + underscored_key = key.replace('.', '_') + if 'value' in value: - underscored_key = key.replace('.', '_') self.variables[underscored_key] = value['value'] + if 'resolutions' in value: + self._populate_resolved_values(underscored_key, value) + + def _populate_resolved_values(self, underscored_key, value): + for resolution in value['resolutions']['resolutionsPerAuthority']: + if 'values' not in resolution: + continue + + for resolved in resolution['values']: + if 'value' not in resolved: + continue + + if 'id' in resolved['value']: + self.variables[underscored_key] = resolved['value']['id'] + elif 'name' in resolved['value']: + self.variables[underscored_key] = resolved['value']['name'] + def add_card(self, card_type, title, content): """Add a card to the response.""" assert self.card is None diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 565ebec64aa..19ecf852622 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -13,6 +13,7 @@ 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" REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" +AUTHORITY_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.ZODIAC" # pylint: disable=invalid-name calls = [] @@ -90,7 +91,7 @@ def alexa_client(loop, hass, test_client): "type": "plain", "text": "LaunchRequest has been received.", } - } + }, } })) return loop.run_until_complete(test_client(hass.http.app)) @@ -207,6 +208,131 @@ def test_intent_request_with_slots(alexa_client): assert text == "You told us your sign is virgo." +@asyncio.coroutine +def test_intent_request_with_slots_and_id_resolution(alexa_client): + """Test a request with slots and an id synonym.""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID + }, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False + } + }, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + } + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "virgo", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": { + "code": "ER_SUCCESS_MATCH" + }, + "values": [ + { + "value": { + "name": "Virgo", + "id": "VIRGO" + } + } + ] + } + ] + } + } + } + } + } + } + req = yield from _intent_req(alexa_client, data) + assert req.status == 200 + data = yield from req.json() + text = data.get("response", {}).get("outputSpeech", + {}).get("text") + assert text == "You told us your sign is VIRGO." + + +@asyncio.coroutine +def test_intent_request_with_slots_and_name_resolution(alexa_client): + """Test a request with slots and a name synonym.""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID + }, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False + } + }, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + } + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "virgo", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": { + "code": "ER_SUCCESS_MATCH" + }, + "values": [ + { + "value": { + "name": "Virgo" + } + } + ] + } + ] + } + } + } + } + } + } + req = yield from _intent_req(alexa_client, data) + assert req.status == 200 + data = yield from req.json() + text = data.get("response", {}).get("outputSpeech", + {}).get("text") + assert text == "You told us your sign is Virgo." + + @asyncio.coroutine def test_intent_request_with_slots_but_no_value(alexa_client): """Test a request with slots but no value.""" From 1c36e2f586c0e08845ad48279cea42fbab38c123 Mon Sep 17 00:00:00 2001 From: Jan Almeroth Date: Fri, 10 Nov 2017 23:41:02 +0100 Subject: [PATCH 064/137] Introduce media progress for Yamaha Musiccast devices (#10256) * Introduce update_hass() * Introduce media_positions * Version bump pymusiccast * Fix: Unnecessary "else" after "return" * FIX D400: First line should end with a period * Version bump Fixes https://github.com/home-assistant/home-assistant/issues/10411 --- .../media_player/yamaha_musiccast.py | 34 +++++++++++++++++-- requirements_all.txt | 2 +- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index 27efc4f3814..bfcffff6bb4 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -10,10 +10,11 @@ media_player: import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.const import ( CONF_HOST, CONF_PORT, - STATE_UNKNOWN, STATE_ON + STATE_UNKNOWN, STATE_ON, STATE_PLAYING, STATE_PAUSED, STATE_IDLE ) from homeassistant.components.media_player import ( MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, @@ -35,7 +36,7 @@ SUPPORTED_FEATURES = ( KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' INTERVAL_SECONDS = 'interval_seconds' -REQUIREMENTS = ['pymusiccast==0.1.3'] +REQUIREMENTS = ['pymusiccast==0.1.5'] DEFAULT_PORT = 5005 DEFAULT_INTERVAL = 480 @@ -111,6 +112,7 @@ class YamahaDevice(MediaPlayerDevice): self._zone = zone self.mute = False self.media_status = None + self.media_status_received = None self.power = STATE_UNKNOWN self.status = STATE_UNKNOWN self.volume = 0 @@ -202,12 +204,34 @@ class YamahaDevice(MediaPlayerDevice): """Title of current playing media.""" return self.media_status.media_title if self.media_status else None + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self.media_status and self.state in \ + [STATE_PLAYING, STATE_PAUSED, STATE_IDLE]: + return self.media_status.media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self.media_status_received if self.media_status else None + def update(self): """Get the latest details from the device.""" _LOGGER.debug("update: %s", self.entity_id) self._recv.update_status() self._zone.update_status() + def update_hass(self): + """Push updates to HASS.""" + if self.entity_id: + _LOGGER.debug("update_hass: pushing updates") + self.schedule_update_ha_state() + return True + def turn_on(self): """Turn on specified media player or all.""" _LOGGER.debug("Turn device: on") @@ -259,3 +283,9 @@ class YamahaDevice(MediaPlayerDevice): _LOGGER.debug("select_source: %s", source) self.status = STATE_UNKNOWN self._zone.set_input(source) + + def new_media_status(self, status): + """Handle updates of the media status.""" + _LOGGER.debug("new media_status arrived") + self.media_status = status + self.media_status_received = dt_util.utcnow() diff --git a/requirements_all.txt b/requirements_all.txt index dec8f96f39a..9b7bb4b3cde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -730,7 +730,7 @@ pymodbus==1.3.1 pymonoprice==0.2 # homeassistant.components.media_player.yamaha_musiccast -pymusiccast==0.1.3 +pymusiccast==0.1.5 # homeassistant.components.cover.myq pymyq==0.0.8 From 5e92fa340468d95502d6b3f9fb968008420e1e47 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 11 Nov 2017 09:02:06 +0200 Subject: [PATCH 065/137] Add an option to serve ES6 JS to clients (#10474) * Add an option to serve ES6 JS to clients * Rename es6 to latest * Fixes * Serve JS vrsions from separate dirs * Revert websocket API change * Update frontend to 20171110.0 * websocket: move request to constructor --- homeassistant/components/frontend/__init__.py | 142 +++++++++++++----- .../components/frontend/templates/index.html | 20 ++- homeassistant/components/http/__init__.py | 1 - homeassistant/components/http/static.py | 3 +- homeassistant/components/websocket_api.py | 17 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/test_frontend.py | 6 +- tests/components/test_panel_iframe.py | 10 +- tests/components/test_websocket_api.py | 12 +- 10 files changed, 150 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 224970499f3..ba09e60b742 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -9,6 +9,7 @@ import hashlib import json import logging import os +from urllib.parse import urlparse from aiohttp import web import voluptuous as vol @@ -21,21 +22,19 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171106.0'] +REQUIREMENTS = ['home-assistant-frontend==20171110.0'] DOMAIN = 'frontend' -DEPENDENCIES = ['api', 'websocket_api'] +DEPENDENCIES = ['api', 'websocket_api', 'http'] -URL_PANEL_COMPONENT = '/frontend/panels/{}.html' URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' -POLYMER_PATH = os.path.join(os.path.dirname(__file__), - 'home-assistant-polymer/') -FINAL_PATH = os.path.join(POLYMER_PATH, 'final') - CONF_THEMES = 'themes' CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_FRONTEND_REPO = 'development_repo' +CONF_JS_VERSION = 'javascript_version' +JS_DEFAULT_OPTION = 'es5' +JS_OPTIONS = ['es5', 'latest', 'auto'] DEFAULT_THEME_COLOR = '#03A9F4' @@ -61,6 +60,7 @@ for size in (192, 384, 512, 1024): DATA_FINALIZE_PANEL = 'frontend_finalize_panel' DATA_PANELS = 'frontend_panels' +DATA_JS_VERSION = 'frontend_js_version' DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' @@ -68,8 +68,6 @@ DEFAULT_THEME = 'default' PRIMARY_COLOR = 'primary-color' -# To keep track we don't register a component twice (gives a warning) -# _REGISTERED_COMPONENTS = set() _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ @@ -80,6 +78,8 @@ CONFIG_SCHEMA = vol.Schema({ }), vol.Optional(CONF_EXTRA_HTML_URL): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_JS_VERSION, default=JS_DEFAULT_OPTION): + vol.In(JS_OPTIONS) }), }, extra=vol.ALLOW_EXTRA) @@ -102,8 +102,9 @@ class AbstractPanel: # Title to show in the sidebar (optional) sidebar_title = None - # Url to the webcomponent - webcomponent_url = None + # Url to the webcomponent (depending on JS version) + webcomponent_url_es5 = None + webcomponent_url_latest = None # Url to show the panel in the frontend frontend_url_path = None @@ -135,16 +136,20 @@ class AbstractPanel: 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), index_view.get) - def as_dict(self): + def to_response(self, hass, request): """Panel as dictionary.""" - return { + result = { 'component_name': self.component_name, 'icon': self.sidebar_icon, 'title': self.sidebar_title, - 'url': self.webcomponent_url, 'url_path': self.frontend_url_path, 'config': self.config, } + if _is_latest(hass.data[DATA_JS_VERSION], request): + result['url'] = self.webcomponent_url_latest + else: + result['url'] = self.webcomponent_url_es5 + return result class BuiltInPanel(AbstractPanel): @@ -170,15 +175,19 @@ class BuiltInPanel(AbstractPanel): if frontend_repository_path is None: import hass_frontend + import hass_frontend_es5 - self.webcomponent_url = \ - '/static/panels/ha-panel-{}-{}.html'.format( + self.webcomponent_url_latest = \ + '/frontend_latest/panels/ha-panel-{}-{}.html'.format( self.component_name, hass_frontend.FINGERPRINTS[panel_path]) - + self.webcomponent_url_es5 = \ + '/frontend_es5/panels/ha-panel-{}-{}.html'.format( + self.component_name, + hass_frontend_es5.FINGERPRINTS[panel_path]) else: # Dev mode - self.webcomponent_url = \ + self.webcomponent_url_es5 = self.webcomponent_url_latest = \ '/home-assistant-polymer/panels/{}/ha-panel-{}.html'.format( self.component_name, self.component_name) @@ -208,18 +217,20 @@ class ExternalPanel(AbstractPanel): """ try: if self.md5 is None: - yield from hass.async_add_job(_fingerprint, self.path) + self.md5 = yield from hass.async_add_job( + _fingerprint, self.path) except OSError: _LOGGER.error('Cannot find or access %s at %s', self.component_name, self.path) hass.data[DATA_PANELS].pop(self.frontend_url_path) + return - self.webcomponent_url = \ + self.webcomponent_url_es5 = self.webcomponent_url_latest = \ URL_PANEL_COMPONENT_FP.format(self.component_name, self.md5) if self.component_name not in self.REGISTERED_COMPONENTS: hass.http.register_static_path( - self.webcomponent_url, self.path, + self.webcomponent_url_latest, self.path, # if path is None, we're in prod mode, so cache static assets frontend_repository_path is None) self.REGISTERED_COMPONENTS.add(self.component_name) @@ -281,31 +292,50 @@ def async_setup(hass, config): repo_path = conf.get(CONF_FRONTEND_REPO) is_dev = repo_path is not None + hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) if is_dev: hass.http.register_static_path( "/home-assistant-polymer", repo_path, False) hass.http.register_static_path( "/static/translations", - os.path.join(repo_path, "build/translations"), False) - sw_path = os.path.join(repo_path, "build/service_worker.js") + os.path.join(repo_path, "build-translations"), False) + sw_path_es5 = os.path.join(repo_path, "build-es5/service_worker.js") + sw_path_latest = os.path.join(repo_path, "build/service_worker.js") static_path = os.path.join(repo_path, 'hass_frontend') + frontend_es5_path = os.path.join(repo_path, 'build-es5') + frontend_latest_path = os.path.join(repo_path, 'build') else: import hass_frontend - frontend_path = hass_frontend.where() - sw_path = os.path.join(frontend_path, "service_worker.js") - static_path = frontend_path + import hass_frontend_es5 + sw_path_es5 = os.path.join(hass_frontend_es5.where(), + "service_worker.js") + sw_path_latest = os.path.join(hass_frontend.where(), + "service_worker.js") + # /static points to dir with files that are JS-type agnostic. + # ES5 files are served from /frontend_es5. + # ES6 files are served from /frontend_latest. + static_path = hass_frontend.where() + frontend_es5_path = hass_frontend_es5.where() + frontend_latest_path = static_path - hass.http.register_static_path("/service_worker.js", sw_path, False) + hass.http.register_static_path( + "/service_worker_es5.js", sw_path_es5, False) + hass.http.register_static_path( + "/service_worker.js", sw_path_latest, False) hass.http.register_static_path( "/robots.txt", os.path.join(static_path, "robots.txt"), not is_dev) hass.http.register_static_path("/static", static_path, not is_dev) + hass.http.register_static_path( + "/frontend_latest", frontend_latest_path, not is_dev) + hass.http.register_static_path( + "/frontend_es5", frontend_es5_path, not is_dev) local = hass.config.path('www') if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(is_dev) + index_view = IndexView(is_dev, js_version) hass.http.register_view(index_view) @asyncio.coroutine @@ -405,7 +435,7 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self, use_repo): + def __init__(self, use_repo, js_option): """Initialize the frontend view.""" from jinja2 import FileSystemLoader, Environment @@ -416,27 +446,37 @@ class IndexView(HomeAssistantView): os.path.join(os.path.dirname(__file__), 'templates/') ) ) + self.js_option = js_option @asyncio.coroutine def get(self, request, extra=None): """Serve the index view.""" hass = request.app['hass'] + latest = _is_latest(self.js_option, request) + compatibility_url = None if self.use_repo: - core_url = '/home-assistant-polymer/build/core.js' - compatibility_url = \ - '/home-assistant-polymer/build/compatibility.js' + core_url = '/home-assistant-polymer/{}/core.js'.format( + 'build' if latest else 'build-es5') ui_url = '/home-assistant-polymer/src/home-assistant.html' icons_fp = '' icons_url = '/static/mdi.html' else: + if latest: + import hass_frontend + core_url = '/frontend_latest/core-{}.js'.format( + hass_frontend.FINGERPRINTS['core.js']) + ui_url = '/frontend_latest/frontend-{}.html'.format( + hass_frontend.FINGERPRINTS['frontend.html']) + else: + import hass_frontend_es5 + core_url = '/frontend_es5/core-{}.js'.format( + hass_frontend_es5.FINGERPRINTS['core.js']) + compatibility_url = '/frontend_es5/compatibility-{}.js'.format( + hass_frontend_es5.FINGERPRINTS['compatibility.js']) + ui_url = '/frontend_es5/frontend-{}.html'.format( + hass_frontend_es5.FINGERPRINTS['frontend.html']) import hass_frontend - core_url = '/static/core-{}.js'.format( - hass_frontend.FINGERPRINTS['core.js']) - compatibility_url = '/static/compatibility-{}.js'.format( - hass_frontend.FINGERPRINTS['compatibility.js']) - ui_url = '/static/frontend-{}.html'.format( - hass_frontend.FINGERPRINTS['frontend.html']) icons_fp = '-{}'.format(hass_frontend.FINGERPRINTS['mdi.html']) icons_url = '/static/mdi{}.html'.format(icons_fp) @@ -447,8 +487,10 @@ class IndexView(HomeAssistantView): if panel == 'states': panel_url = '' + elif latest: + panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_latest else: - panel_url = hass.data[DATA_PANELS][panel].webcomponent_url + panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_es5 no_auth = 'true' if hass.config.api.api_password and not is_trusted_ip(request): @@ -468,7 +510,10 @@ class IndexView(HomeAssistantView): panel_url=panel_url, panels=hass.data[DATA_PANELS], dev_mode=self.use_repo, theme_color=MANIFEST_JSON['theme_color'], - extra_urls=hass.data[DATA_EXTRA_HTML_URL]) + extra_urls=hass.data[DATA_EXTRA_HTML_URL], + latest=latest, + service_worker_name='/service_worker.js' if latest else + '/service_worker_es5.js') return web.Response(text=resp, content_type='text/html') @@ -509,3 +554,20 @@ def _fingerprint(path): """Fingerprint a file.""" with open(path) as fil: return hashlib.md5(fil.read().encode('utf-8')).hexdigest() + + +def _is_latest(js_option, request): + """ + Return whether we should serve latest untranspiled code. + + Set according to user's preference and URL override. + """ + if request is None: + return js_option == 'latest' + latest_in_query = 'latest' in request.query or ( + request.headers.get('Referer') and + 'latest' in urlparse(request.headers['Referer']).query) + es5_in_query = 'es5' in request.query or ( + request.headers.get('Referer') and + 'es5' in urlparse(request.headers['Referer']).query) + return latest_in_query or (not es5_in_query and js_option == 'latest') diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index c941fbc15ae..ae030a5d026 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -78,11 +78,11 @@ TRY AGAIN - - {# #} + + {# -#} + {% if not latest -%} + {% endif -%} - {% if not dev_mode %} - - {% endif %} + {% if not dev_mode and not latest -%} + + {% endif -%} {% if panel_url -%} diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 659fd026bb8..17ceccfd218 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -262,7 +262,6 @@ class HomeAssistantWSGI(object): resource = CachingStaticResource else: resource = web.StaticResource - self.app.router.register_resource(resource(url_path, path)) return diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index c2576358f59..c9b094e3f2e 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -65,7 +65,8 @@ class CachingFileResponse(FileResponse): @asyncio.coroutine def staticresource_middleware(request, handler): """Middleware to strip out fingerprint from fingerprinted assets.""" - if not request.path.startswith('/static/'): + path = request.path + if not path.startswith('/static/') and not path.startswith('/frontend'): return handler(request) fingerprinted = _FINGERPRINT.match(request.match_info['filename']) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index e9f567c04d3..a1fb0ca9cac 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -202,15 +202,16 @@ class WebsocketAPIView(HomeAssistantView): def get(self, request): """Handle an incoming websocket connection.""" # pylint: disable=no-self-use - return ActiveConnection(request.app['hass']).handle(request) + return ActiveConnection(request.app['hass'], request).handle() class ActiveConnection: """Handle an active websocket client connection.""" - def __init__(self, hass): + def __init__(self, hass, request): """Initialize an active connection.""" self.hass = hass + self.request = request self.wsock = None self.event_listeners = {} self.to_write = asyncio.Queue(maxsize=MAX_PENDING_MSG, loop=hass.loop) @@ -259,8 +260,9 @@ class ActiveConnection: self._writer_task.cancel() @asyncio.coroutine - def handle(self, request): + def handle(self): """Handle the websocket connection.""" + request = self.request wsock = self.wsock = web.WebSocketResponse() yield from wsock.prepare(request) self.debug("Connected") @@ -350,7 +352,7 @@ class ActiveConnection: if wsock.closed: self.debug("Connection closed by client") else: - self.log_error("Unexpected TypeError", msg) + _LOGGER.exception("Unexpected TypeError: %s", msg) except ValueError as err: msg = "Received invalid JSON" @@ -483,9 +485,14 @@ class ActiveConnection: Async friendly. """ msg = GET_PANELS_MESSAGE_SCHEMA(msg) + panels = { + panel: + self.hass.data[frontend.DATA_PANELS][panel].to_response( + self.hass, self.request) + for panel in self.hass.data[frontend.DATA_PANELS]} self.to_write.put_nowait(result_message( - msg['id'], self.hass.data[frontend.DATA_PANELS])) + msg['id'], panels)) def handle_ping(self, msg): """Handle ping command. diff --git a/requirements_all.txt b/requirements_all.txt index 9b7bb4b3cde..782e9930daa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,7 +330,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171106.0 +home-assistant-frontend==20171110.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f45cc4516e..083c2792db2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171106.0 +home-assistant-frontend==20171110.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 1b034cfe940..3d8d2b62a2b 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -52,7 +52,7 @@ def test_frontend_and_static(mock_http_client): # Test we can retrieve frontend.js frontendjs = re.search( - r'(?P\/static\/frontend-[A-Za-z0-9]{32}.html)', text) + r'(?P\/frontend_es5\/frontend-[A-Za-z0-9]{32}.html)', text) assert frontendjs is not None resp = yield from mock_http_client.get(frontendjs.groups(0)[0]) @@ -63,6 +63,10 @@ def test_frontend_and_static(mock_http_client): @asyncio.coroutine def test_dont_cache_service_worker(mock_http_client): """Test that we don't cache the service worker.""" + resp = yield from mock_http_client.get('/service_worker_es5.js') + assert resp.status == 200 + assert 'cache-control' not in resp.headers + resp = yield from mock_http_client.get('/service_worker.js') assert resp.status == 200 assert 'cache-control' not in resp.headers diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index 00c824418be..9a56479c469 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -33,7 +33,7 @@ class TestPanelIframe(unittest.TestCase): 'panel_iframe': conf }) - @patch.dict('hass_frontend.FINGERPRINTS', + @patch.dict('hass_frontend_es5.FINGERPRINTS', {'panels/ha-panel-iframe.html': 'md5md5'}) def test_correct_config(self): """Test correct config.""" @@ -55,20 +55,20 @@ class TestPanelIframe(unittest.TestCase): panels = self.hass.data[frontend.DATA_PANELS] - assert panels.get('router').as_dict() == { + assert panels.get('router').to_response(self.hass, None) == { 'component_name': 'iframe', 'config': {'url': 'http://192.168.1.1'}, 'icon': 'mdi:network-wireless', 'title': 'Router', - 'url': '/static/panels/ha-panel-iframe-md5md5.html', + 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'router' } - assert panels.get('weather').as_dict() == { + assert panels.get('weather').to_response(self.hass, None) == { 'component_name': 'iframe', 'config': {'url': 'https://www.wunderground.com/us/ca/san-diego'}, 'icon': 'mdi:weather', 'title': 'Weather', - 'url': '/static/panels/ha-panel-iframe-md5md5.html', + 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'weather', } diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index c310b0d5445..8b6c7494214 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -290,7 +290,7 @@ def test_get_panels(hass, websocket_client): """Test get_panels command.""" yield from hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:account-location') - + hass.data[frontend.DATA_JS_VERSION] = 'es5' websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_PANELS, @@ -300,8 +300,14 @@ def test_get_panels(hass, websocket_client): assert msg['id'] == 5 assert msg['type'] == wapi.TYPE_RESULT assert msg['success'] - assert msg['result'] == {url: panel.as_dict() for url, panel - in hass.data[frontend.DATA_PANELS].items()} + assert msg['result'] == {'map': { + 'component_name': 'map', + 'url_path': 'map', + 'config': None, + 'url': None, + 'icon': 'mdi:account-location', + 'title': 'Map', + }} @asyncio.coroutine From 44506ce15f71bf223b463f686fe9476919cdc4b0 Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Sat, 11 Nov 2017 17:36:37 +0100 Subject: [PATCH 066/137] Adapt to new yarl API (#10527) --- homeassistant/components/tts/google.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 4551a792fc6..e405e5be531 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -87,7 +87,7 @@ class GoogleProvider(Provider): url_param = { 'ie': 'UTF-8', 'tl': language, - 'q': yarl.quote(part, strict=False), + 'q': yarl.quote(part), 'tk': part_token, 'total': len(message_parts), 'idx': idx, From f3a90d69946890ee93ce59c6644ed6c27c0163a1 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Sat, 11 Nov 2017 20:51:26 +0100 Subject: [PATCH 067/137] Update nederlandse_spoorwegen.py to include platform information (#10494) * Update nederlandse_spoorwegen.py Make departure and arrival platforms available as state attributes * Update nederlandse_spoorwegen.py * Update nederlandse_spoorwegen.py --- homeassistant/components/sensor/nederlandse_spoorwegen.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sensor/nederlandse_spoorwegen.py b/homeassistant/components/sensor/nederlandse_spoorwegen.py index e8d3aa41c6c..3535e00d79b 100644 --- a/homeassistant/components/sensor/nederlandse_spoorwegen.py +++ b/homeassistant/components/sensor/nederlandse_spoorwegen.py @@ -135,6 +135,10 @@ class NSDepartureSensor(Entity): 'departure_delay': self._trips[0].departure_time_planned != self._trips[0].departure_time_actual, + 'departure_platform': + self._trips[0].trip_parts[0].stops[0].platform, + 'departure_platform_changed': + self._trips[0].trip_parts[0].stops[0].platform_changed, 'arrival_time_planned': self._trips[0].arrival_time_planned.strftime('%H:%M'), 'arrival_time_actual': @@ -142,6 +146,10 @@ class NSDepartureSensor(Entity): 'arrival_delay': self._trips[0].arrival_time_planned != self._trips[0].arrival_time_actual, + 'arrival_platform': + self._trips[0].trip_parts[0].stops[-1].platform, + 'arrival_platform_changed': + self._trips[0].trip_parts[0].stops[-1].platform_changed, 'next': self._trips[1].departure_time_actual.strftime('%H:%M'), 'status': self._trips[0].status.lower(), From b284cc54df49a41613d9977ee5b755276e167787 Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Sat, 11 Nov 2017 21:15:13 +0100 Subject: [PATCH 068/137] Pin yarl (#10528) * Pin yarl * Update requirements --- homeassistant/package_constraints.txt | 3 ++- requirements_all.txt | 3 ++- setup.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 00df81290e5..056ed2f3fa6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,8 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.1 +aiohttp==2.3.2 +yarl==0.14.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 782e9930daa..3c17f5aee43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,8 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.1 +aiohttp==2.3.2 +yarl==0.14.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/setup.py b/setup.py index 25c38af27fb..f7a3e4ab8f3 100755 --- a/setup.py +++ b/setup.py @@ -53,7 +53,8 @@ REQUIRES = [ 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.3.1', + 'aiohttp==2.3.2', # If updated, check if yarl also needs an update! + 'yarl==0.14.0', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', From 75836affbe3ec118b75d8b691f93a9c67ea0d8bb Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Sat, 11 Nov 2017 21:21:25 +0100 Subject: [PATCH 069/137] Support configuration of region (no service url neccessary (#10513) --- homeassistant/components/volvooncall.py | 9 ++++++--- requirements_all.txt | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 9c8366e7f7e..4cee6ea2139 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -22,13 +22,14 @@ DOMAIN = 'volvooncall' DATA_KEY = DOMAIN -REQUIREMENTS = ['volvooncall==0.3.3'] +REQUIREMENTS = ['volvooncall==0.4.0'] _LOGGER = logging.getLogger(__name__) CONF_UPDATE_INTERVAL = 'update_interval' MIN_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) +CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) @@ -58,6 +59,7 @@ CONFIG_SCHEMA = vol.Schema({ {cv.slug: cv.string}), vol.Optional(CONF_RESOURCES): vol.All( cv.ensure_list, [vol.In(RESOURCES)]), + vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, }), }, extra=vol.ALLOW_EXTRA) @@ -65,11 +67,12 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up the Volvo On Call component.""" - from volvooncall import Connection, DEFAULT_SERVICE_URL + from volvooncall import Connection connection = Connection( config[DOMAIN].get(CONF_USERNAME), config[DOMAIN].get(CONF_PASSWORD), - config[DOMAIN].get(CONF_SERVICE_URL, DEFAULT_SERVICE_URL)) + config[DOMAIN].get(CONF_SERVICE_URL), + config[DOMAIN].get(CONF_REGION)) interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) diff --git a/requirements_all.txt b/requirements_all.txt index 3c17f5aee43..b9d306f2e81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1094,7 +1094,7 @@ upsmychoice==1.0.6 uvcclient==0.10.1 # homeassistant.components.volvooncall -volvooncall==0.3.3 +volvooncall==0.4.0 # homeassistant.components.verisure vsure==1.3.7 From 4420f11d9d1e1bc897a24344aa1dfda58dce462d Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 11 Nov 2017 22:24:43 +0200 Subject: [PATCH 070/137] Fix import in tests (#10525) --- tests/components/binary_sensor/test_vultr.py | 6 +++--- tests/components/sensor/test_vultr.py | 6 +++--- tests/components/switch/test_vultr.py | 6 +++--- tests/components/test_vultr.py | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/components/binary_sensor/test_vultr.py b/tests/components/binary_sensor/test_vultr.py index 7b0cc8caa87..2bcb220233b 100644 --- a/tests/components/binary_sensor/test_vultr.py +++ b/tests/components/binary_sensor/test_vultr.py @@ -4,9 +4,9 @@ import requests_mock import pytest import voluptuous as vol -from components.binary_sensor import vultr -from components import vultr as base_vultr -from components.vultr import ( +from homeassistant.components.binary_sensor import vultr +from homeassistant.components import vultr as base_vultr +from homeassistant.components.vultr import ( ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_IPV4_ADDRESS, ATTR_COST_PER_MONTH, ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, CONF_SUBSCRIPTION) diff --git a/tests/components/sensor/test_vultr.py b/tests/components/sensor/test_vultr.py index ba5730f4acf..a4e5edc5800 100644 --- a/tests/components/sensor/test_vultr.py +++ b/tests/components/sensor/test_vultr.py @@ -4,9 +4,9 @@ import unittest import requests_mock import voluptuous as vol -from components.sensor import vultr -from components import vultr as base_vultr -from components.vultr import CONF_SUBSCRIPTION +from homeassistant.components.sensor import vultr +from homeassistant.components import vultr as base_vultr +from homeassistant.components.vultr import CONF_SUBSCRIPTION from homeassistant.const import ( CONF_NAME, CONF_MONITORED_CONDITIONS, CONF_PLATFORM) diff --git a/tests/components/switch/test_vultr.py b/tests/components/switch/test_vultr.py index e5eb8800f98..53bf6fbec85 100644 --- a/tests/components/switch/test_vultr.py +++ b/tests/components/switch/test_vultr.py @@ -4,9 +4,9 @@ import requests_mock import pytest import voluptuous as vol -from components.switch import vultr -from components import vultr as base_vultr -from components.vultr import ( +from homeassistant.components.switch import vultr +from homeassistant.components import vultr as base_vultr +from homeassistant.components.vultr import ( ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_IPV4_ADDRESS, ATTR_COST_PER_MONTH, ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, CONF_SUBSCRIPTION) diff --git a/tests/components/test_vultr.py b/tests/components/test_vultr.py index ddddcd2be6c..b504c320dc8 100644 --- a/tests/components/test_vultr.py +++ b/tests/components/test_vultr.py @@ -4,7 +4,7 @@ import requests_mock from copy import deepcopy from homeassistant import setup -import components.vultr as vultr +import homeassistant.components.vultr as vultr from tests.common import ( get_test_home_assistant, load_fixture) From 68fb995c63fc131cfa7053adb1746b0d56117298 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Sat, 11 Nov 2017 21:30:18 +0100 Subject: [PATCH 071/137] Update axis.py (#10412) --- homeassistant/components/axis.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index 18f2c054b0c..401afe8c62c 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -269,7 +269,8 @@ def setup_device(hass, config, device_config): config) AXIS_DEVICES[device.serial_number] = device - hass.add_job(device.start) + if event_types: + hass.add_job(device.start) return True From db56748d889a1caf7bb90d3414b56700675ca6f5 Mon Sep 17 00:00:00 2001 From: Martin Berg Date: Sat, 11 Nov 2017 21:36:03 +0100 Subject: [PATCH 072/137] Add attribute to show who last un/set alarm (SPC) (#9906) * Add attribute to show who last un/set alarm. This allows showing the name of the SPC user who last issued an arm/disarm command and also allows for automations to depend on this value. * Optimize * Update spc.py * Update spc.py * fix * Fix test. * Fix for removed is_state_attr. --- .../components/alarm_control_panel/spc.py | 39 ++++++++----- homeassistant/components/binary_sensor/spc.py | 2 +- homeassistant/components/spc.py | 10 +++- .../alarm_control_panel/test_spc.py | 14 +++-- tests/components/binary_sensor/test_spc.py | 6 +- tests/components/test_spc.py | 56 ++++++++++++------- 6 files changed, 82 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index 1682ef2ae02..4d9c72df2f1 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -34,10 +34,8 @@ def async_setup_platform(hass, config, async_add_devices, 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'])) + api = hass.data[DATA_API] + devices = [SpcAlarm(api, area) for area in discovery_info[ATTR_DISCOVER_AREAS]] async_add_devices(devices) @@ -46,21 +44,29 @@ def async_setup_platform(hass, config, async_add_devices, class SpcAlarm(alarm.AlarmControlPanel): """Represents the SPC alarm panel.""" - def __init__(self, hass, area_id, name, state): + def __init__(self, api, area): """Initialize the SPC alarm panel.""" - self._hass = hass - self._area_id = area_id - self._name = name - self._state = state - self._api = hass.data[DATA_API] - - hass.data[DATA_REGISTRY].register_alarm_device(area_id, self) + self._area_id = area['id'] + self._name = area['name'] + self._state = _get_alarm_state(area['mode']) + if self._state == STATE_ALARM_DISARMED: + self._changed_by = area.get('last_unset_user_name', 'unknown') + else: + self._changed_by = area.get('last_set_user_name', 'unknown') + self._api = api @asyncio.coroutine - def async_update_from_spc(self, state): + def async_added_to_hass(self): + """Calbback for init handlers.""" + self.hass.data[DATA_REGISTRY].register_alarm_device( + self._area_id, self) + + @asyncio.coroutine + def async_update_from_spc(self, state, extra): """Update the alarm panel with a new state.""" self._state = state - yield from self.async_update_ha_state() + self._changed_by = extra.get('changed_by', 'unknown') + self.async_schedule_update_ha_state() @property def should_poll(self): @@ -72,6 +78,11 @@ class SpcAlarm(alarm.AlarmControlPanel): """Return the name of the device.""" return self._name + @property + def changed_by(self): + """Return the user the last change was triggered by.""" + return self._changed_by + @property def state(self): """Return the state of the device.""" diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index af3669c2b15..a3a84580edd 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -67,7 +67,7 @@ class SpcBinarySensor(BinarySensorDevice): spc_registry.register_sensor_device(zone_id, self) @asyncio.coroutine - def async_update_from_spc(self, state): + def async_update_from_spc(self, state, extra): """Update the state of the device.""" self._state = state yield from self.async_update_ha_state() diff --git a/homeassistant/components/spc.py b/homeassistant/components/spc.py index a271297d0fd..c186559c91a 100644 --- a/homeassistant/components/spc.py +++ b/homeassistant/components/spc.py @@ -87,9 +87,14 @@ def _async_process_message(sia_message, spc_registry): # ZX - Zone Short # ZD - Zone Disconnected - if sia_code in ('BA', 'CG', 'NL', 'OG', 'OQ'): + extra = {} + + if sia_code in ('BA', 'CG', 'NL', 'OG'): # change in area status, notify alarm panel device device = spc_registry.get_alarm_device(spc_id) + data = sia_message['description'].split('¦') + if len(data) == 3: + extra['changed_by'] = data[1] else: # change in zone status, notify sensor device device = spc_registry.get_sensor_device(spc_id) @@ -98,7 +103,6 @@ def _async_process_message(sia_message, spc_registry): 'CG': STATE_ALARM_ARMED_AWAY, 'NL': STATE_ALARM_ARMED_HOME, 'OG': STATE_ALARM_DISARMED, - 'OQ': STATE_ALARM_DISARMED, 'ZO': STATE_ON, 'ZC': STATE_OFF, 'ZX': STATE_UNKNOWN, @@ -110,7 +114,7 @@ def _async_process_message(sia_message, spc_registry): _LOGGER.warning("No device mapping found for SPC area/zone id %s.", spc_id) elif new_state: - yield from device.async_update_from_spc(new_state) + yield from device.async_update_from_spc(new_state, extra) class SpcRegistry: diff --git a/tests/components/alarm_control_panel/test_spc.py b/tests/components/alarm_control_panel/test_spc.py index 504b4e9237c..63b79781404 100644 --- a/tests/components/alarm_control_panel/test_spc.py +++ b/tests/components/alarm_control_panel/test_spc.py @@ -7,7 +7,7 @@ from homeassistant.components.spc import SpcRegistry from homeassistant.components.alarm_control_panel import spc from tests.common import async_test_home_assistant from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED) @pytest.fixture @@ -38,19 +38,19 @@ def test_setup_platform(hass): 'last_set_user_name': 'Pelle', 'last_unset_time': '1485800564', 'last_unset_user_id': '1', - 'last_unset_user_name': 'Pelle', + 'last_unset_user_name': 'Lisa', 'last_alarm': '1478174896' - }, { + }, { 'id': '3', 'name': 'Garage', 'mode': '0', 'last_set_time': '1483705803', 'last_set_user_id': '9998', - 'last_set_user_name': 'Lisa', + 'last_set_user_name': 'Pelle', 'last_unset_time': '1483705808', 'last_unset_user_id': '9998', 'last_unset_user_name': 'Lisa' - }]} + }]} yield from spc.async_setup_platform(hass=hass, config={}, @@ -58,7 +58,11 @@ def test_setup_platform(hass): discovery_info=areas) assert len(added_entities) == 2 + assert added_entities[0].name == 'House' assert added_entities[0].state == STATE_ALARM_ARMED_AWAY + assert added_entities[0].changed_by == 'Pelle' + assert added_entities[1].name == 'Garage' assert added_entities[1].state == STATE_ALARM_DISARMED + assert added_entities[1].changed_by == 'Lisa' diff --git a/tests/components/binary_sensor/test_spc.py b/tests/components/binary_sensor/test_spc.py index 5004ccd3210..d2299874527 100644 --- a/tests/components/binary_sensor/test_spc.py +++ b/tests/components/binary_sensor/test_spc.py @@ -30,7 +30,7 @@ def test_setup_platform(hass): 'area_name': 'House', 'input': '0', 'status': '0', - }, { + }, { 'id': '3', 'type': '0', 'zone_name': 'Hallway PIR', @@ -38,7 +38,7 @@ def test_setup_platform(hass): 'area_name': 'House', 'input': '0', 'status': '0', - }, { + }, { 'id': '5', 'type': '1', 'zone_name': 'Front door', @@ -46,7 +46,7 @@ def test_setup_platform(hass): 'area_name': 'House', 'input': '1', 'status': '0', - }]} + }]} def add_entities(entities): nonlocal added_entities diff --git a/tests/components/test_spc.py b/tests/components/test_spc.py index 6fae8d821c2..7837abd8007 100644 --- a/tests/components/test_spc.py +++ b/tests/components/test_spc.py @@ -7,7 +7,9 @@ from homeassistant.components import spc from homeassistant.bootstrap import async_setup_component from tests.common import async_test_home_assistant from tests.test_util.aiohttp import mock_aiohttp_client -from homeassistant.const import (STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) @pytest.fixture @@ -57,7 +59,13 @@ def aioclient_mock(): @asyncio.coroutine -def test_update_alarm_device(hass, aioclient_mock, monkeypatch): +@pytest.mark.parametrize("sia_code,state", [ + ('NL', STATE_ALARM_ARMED_HOME), + ('CG', STATE_ALARM_ARMED_AWAY), + ('OG', STATE_ALARM_DISARMED) +]) +def test_update_alarm_device(hass, aioclient_mock, monkeypatch, + sia_code, state): """Test that alarm panel state changes on incoming websocket data.""" monkeypatch.setattr("homeassistant.components.spc.SpcWebGateway." "start_listener", lambda x, *args: None) @@ -65,8 +73,8 @@ def test_update_alarm_device(hass, aioclient_mock, monkeypatch): 'spc': { 'api_url': 'http://localhost/', 'ws_url': 'ws://localhost/' - } } + } yield from async_setup_component(hass, 'spc', config) yield from hass.async_block_till_done() @@ -74,38 +82,48 @@ def test_update_alarm_device(hass, aioclient_mock, monkeypatch): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - msg = {"sia_code": "NL", "sia_address": "1", "description": "House|Sam|1"} + msg = {"sia_code": sia_code, "sia_address": "1", + "description": "House¦Sam¦1"} yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME + yield from hass.async_block_till_done() - msg = {"sia_code": "OQ", "sia_address": "1", "description": "Sam"} - yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED + state_obj = hass.states.get(entity_id) + assert state_obj.state == state + assert state_obj.attributes['changed_by'] == 'Sam' @asyncio.coroutine -def test_update_sensor_device(hass, aioclient_mock, monkeypatch): - """Test that sensors change state on incoming websocket data.""" +@pytest.mark.parametrize("sia_code,state", [ + ('ZO', STATE_ON), + ('ZC', STATE_OFF) +]) +def test_update_sensor_device(hass, aioclient_mock, monkeypatch, + sia_code, state): + """ + Test that sensors change state on incoming websocket data. + + Note that we don't test for the ZD (disconnected) and ZX (problem/short) + codes since the binary sensor component is hardcoded to only + let on/off states through. + """ monkeypatch.setattr("homeassistant.components.spc.SpcWebGateway." "start_listener", lambda x, *args: None) config = { 'spc': { 'api_url': 'http://localhost/', 'ws_url': 'ws://localhost/' - } } + } yield from async_setup_component(hass, 'spc', config) yield from hass.async_block_till_done() - assert hass.states.get('binary_sensor.hallway_pir').state == 'off' + assert hass.states.get('binary_sensor.hallway_pir').state == STATE_OFF - msg = {"sia_code": "ZO", "sia_address": "3", "description": "Hallway PIR"} + msg = {"sia_code": sia_code, "sia_address": "3", + "description": "Hallway PIR"} yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - assert hass.states.get('binary_sensor.hallway_pir').state == 'on' - - msg = {"sia_code": "ZC", "sia_address": "3", "description": "Hallway PIR"} - yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - assert hass.states.get('binary_sensor.hallway_pir').state == 'off' + yield from hass.async_block_till_done() + assert hass.states.get('binary_sensor.hallway_pir').state == state class TestSpcRegistry: @@ -139,7 +157,7 @@ class TestSpcWebGateway: ('set', spc.SpcWebGateway.AREA_COMMAND_SET), ('unset', spc.SpcWebGateway.AREA_COMMAND_UNSET), ('set_a', spc.SpcWebGateway.AREA_COMMAND_PART_SET) - ]) + ]) def test_area_commands(self, spcwebgw, url_command, command): """Test alarm arming/disarming.""" with mock_aiohttp_client() as aioclient_mock: From b6e098d1c283ad8726fc6ecb32e870e003a0c99f Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Sat, 11 Nov 2017 15:49:20 -0500 Subject: [PATCH 073/137] Fixed Wink Quirky Aros bugs. (#10533) * Fixed Wink Quirky Aros bugs. --- homeassistant/components/climate/wink.py | 44 +++++++++++++++--------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index f72cefc0841..75627f11a71 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -139,7 +139,7 @@ class WinkThermostat(WinkDevice, ClimateDevice): @property def eco_target(self): - """Return status of eco target (Is the termostat in eco mode).""" + """Return status of eco target (Is the thermostat in eco mode).""" return self.wink.eco_target() @property @@ -249,7 +249,7 @@ class WinkThermostat(WinkDevice, ClimateDevice): if ha_mode is not None: op_list.append(ha_mode) else: - error = "Invaid operation mode mapping. " + mode + \ + error = "Invalid operation mode mapping. " + mode + \ " doesn't map. Please report this." _LOGGER.error(error) return op_list @@ -297,7 +297,6 @@ class WinkThermostat(WinkDevice, ClimateDevice): minimum = 7 # Default minimum min_min = self.wink.min_min_set_point() min_max = self.wink.min_max_set_point() - return_value = minimum if self.current_operation == STATE_HEAT: if min_min: return_value = min_min @@ -323,7 +322,6 @@ class WinkThermostat(WinkDevice, ClimateDevice): maximum = 35 # Default maximum max_min = self.wink.max_min_set_point() max_max = self.wink.max_max_set_point() - return_value = maximum if self.current_operation == STATE_HEAT: if max_min: return_value = max_min @@ -377,11 +375,14 @@ class WinkAC(WinkDevice, ClimateDevice): @property def current_operation(self): - """Return current operation ie. heat, cool, idle.""" + """Return current operation ie. auto_eco, cool_only, fan_only.""" if not self.wink.is_on(): current_op = STATE_OFF else: - current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + wink_mode = self.wink.current_mode() + if wink_mode == "auto_eco": + wink_mode = "eco" + current_op = WINK_STATE_TO_HA.get(wink_mode) if current_op is None: current_op = STATE_UNKNOWN return current_op @@ -392,11 +393,13 @@ class WinkAC(WinkDevice, ClimateDevice): op_list = ['off'] modes = self.wink.modes() for mode in modes: + if mode == "auto_eco": + mode = "eco" ha_mode = WINK_STATE_TO_HA.get(mode) if ha_mode is not None: op_list.append(ha_mode) else: - error = "Invaid operation mode mapping. " + mode + \ + error = "Invalid operation mode mapping. " + mode + \ " doesn't map. Please report this." _LOGGER.error(error) return op_list @@ -420,15 +423,19 @@ class WinkAC(WinkDevice, ClimateDevice): @property def current_fan_mode(self): - """Return the current fan mode.""" + """ + Return the current fan mode. + + The official Wink app only supports 3 modes [low, medium, high] + which are equal to [0.33, 0.66, 1.0] respectively. + """ speed = self.wink.current_fan_speed() - if speed <= 0.4 and speed > 0.3: + if speed <= 0.33: return SPEED_LOW - elif speed <= 0.8 and speed > 0.5: + elif speed <= 0.66: return SPEED_MEDIUM - elif speed <= 1.0 and speed > 0.8: + else: return SPEED_HIGH - return STATE_UNKNOWN @property def fan_list(self): @@ -436,11 +443,16 @@ class WinkAC(WinkDevice, ClimateDevice): return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] def set_fan_mode(self, fan): - """Set fan speed.""" + """ + Set fan speed. + + The official Wink app only supports 3 modes [low, medium, high] + which are equal to [0.33, 0.66, 1.0] respectively. + """ if fan == SPEED_LOW: - speed = 0.4 + speed = 0.33 elif fan == SPEED_MEDIUM: - speed = 0.8 + speed = 0.66 elif fan == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) @@ -492,7 +504,7 @@ class WinkWaterHeater(WinkDevice, ClimateDevice): if ha_mode is not None: op_list.append(ha_mode) else: - error = "Invaid operation mode mapping. " + mode + \ + error = "Invalid operation mode mapping. " + mode + \ " doesn't map. Please report this." _LOGGER.error(error) return op_list From 79001fc361b6adb9377638acd41dd3b1c64bc0f9 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 11 Nov 2017 15:21:03 -0700 Subject: [PATCH 074/137] =?UTF-8?q?Adds=20support=20for=20Tile=C2=AE=20Blu?= =?UTF-8?q?etooth=20trackers=20(#10478)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial work in place * Added new attributes + client UUID storage * Wrapped up * Collaborator-requested changes --- .coveragerc | 1 + .../components/device_tracker/tile.py | 124 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 128 insertions(+) create mode 100644 homeassistant/components/device_tracker/tile.py diff --git a/.coveragerc b/.coveragerc index 3bfd983dc30..390e57e2e31 100644 --- a/.coveragerc +++ b/.coveragerc @@ -325,6 +325,7 @@ omit = homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tado.py + homeassistant/components/device_tracker/tile.py homeassistant/components/device_tracker/tplink.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py new file mode 100644 index 00000000000..f27a950a49f --- /dev/null +++ b/homeassistant/components/device_tracker/tile.py @@ -0,0 +1,124 @@ +""" +Support for Tile® Bluetooth trackers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tile/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_USERNAME, CONF_MONITORED_VARIABLES, CONF_PASSWORD) +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify +from homeassistant.util.json import load_json, save_json + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pytile==1.0.0'] + +CLIENT_UUID_CONFIG_FILE = '.tile.conf' +DEFAULT_ICON = 'mdi:bluetooth' +DEVICE_TYPES = ['PHONE', 'TILE'] + +ATTR_ALTITUDE = 'altitude' +ATTR_CONNECTION_STATE = 'connection_state' +ATTR_IS_DEAD = 'is_dead' +ATTR_IS_LOST = 'is_lost' +ATTR_LAST_SEEN = 'last_seen' +ATTR_LAST_UPDATED = 'last_updated' +ATTR_RING_STATE = 'ring_state' +ATTR_VOIP_STATE = 'voip_state' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES): + vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), +}) + + +def setup_scanner(hass, config: dict, see, discovery_info=None): + """Validate the configuration and return a Tile scanner.""" + TileDeviceScanner(hass, config, see) + return True + + +class TileDeviceScanner(DeviceScanner): + """Define a device scanner for Tiles.""" + + def __init__(self, hass, config, see): + """Initialize.""" + from pytile import Client + + _LOGGER.debug('Received configuration data: %s', config) + + # Load the client UUID (if it exists): + config_data = load_json(hass.config.path(CLIENT_UUID_CONFIG_FILE)) + if config_data: + _LOGGER.debug('Using existing client UUID') + self._client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD], + config_data['client_uuid']) + else: + _LOGGER.debug('Generating new client UUID') + self._client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD]) + + if not save_json( + hass.config.path(CLIENT_UUID_CONFIG_FILE), + {'client_uuid': self._client.client_uuid}): + _LOGGER.error("Failed to save configuration file") + + _LOGGER.debug('Client UUID: %s', self._client.client_uuid) + _LOGGER.debug('User UUID: %s', self._client.user_uuid) + + self._types = config.get(CONF_MONITORED_VARIABLES) + + self.devices = {} + self.see = see + + track_utc_time_change( + hass, self._update_info, second=range(0, 60, 30)) + + self._update_info() + + def _update_info(self, now=None) -> None: + """Update the device info.""" + device_data = self._client.get_tiles(type_whitelist=self._types) + + try: + self.devices = device_data['result'] + except KeyError: + _LOGGER.warning('No Tiles found') + _LOGGER.debug(device_data) + return + + for info in self.devices.values(): + dev_id = 'tile_{0}'.format(slugify(info['name'])) + lat = info['tileState']['latitude'] + lon = info['tileState']['longitude'] + + attrs = { + ATTR_ALTITUDE: info['tileState']['altitude'], + ATTR_CONNECTION_STATE: info['tileState']['connection_state'], + ATTR_IS_DEAD: info['is_dead'], + ATTR_IS_LOST: info['tileState']['is_lost'], + ATTR_LAST_SEEN: info['tileState']['timestamp'], + ATTR_LAST_UPDATED: device_data['timestamp_ms'], + ATTR_RING_STATE: info['tileState']['ring_state'], + ATTR_VOIP_STATE: info['tileState']['voip_state'], + } + + self.see( + dev_id=dev_id, + gps=(lat, lon), + attributes=attrs, + icon=DEFAULT_ICON + ) diff --git a/requirements_all.txt b/requirements_all.txt index b9d306f2e81..9f4a911b6b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -893,6 +893,9 @@ pythonegardia==1.0.22 # homeassistant.components.sensor.whois pythonwhois==2.4.3 +# homeassistant.components.device_tracker.tile +pytile==1.0.0 + # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From 96e7944fa8303c5b27efe8b0aff35000869643d2 Mon Sep 17 00:00:00 2001 From: Vignesh Venkat Date: Sat, 11 Nov 2017 15:13:35 -0800 Subject: [PATCH 075/137] telegram_bot: Support for sending videos (#10470) * telegram_bot: Support for sending videos Telegram python library has a sendVideo function that can be used similar to sending photos and documents. * fix lint issue * fix grammar --- homeassistant/components/notify/telegram.py | 11 ++++++- .../components/telegram_bot/__init__.py | 21 ++++++++----- .../components/telegram_bot/services.yaml | 31 +++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index fb453263dd8..899ccf9b09a 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -21,6 +21,7 @@ DEPENDENCIES = [DOMAIN] ATTR_KEYBOARD = 'keyboard' ATTR_INLINE_KEYBOARD = 'inline_keyboard' ATTR_PHOTO = 'photo' +ATTR_VIDEO = 'video' ATTR_DOCUMENT = 'document' CONF_CHAT_ID = 'chat_id' @@ -63,7 +64,7 @@ class TelegramNotificationService(BaseNotificationService): keys = keys if isinstance(keys, list) else [keys] service_data.update(inline_keyboard=keys) - # Send a photo, a document or a location + # Send a photo, video, document, or location if data is not None and ATTR_PHOTO in data: photos = data.get(ATTR_PHOTO, None) photos = photos if isinstance(photos, list) else [photos] @@ -72,6 +73,14 @@ class TelegramNotificationService(BaseNotificationService): self.hass.services.call( DOMAIN, 'send_photo', service_data=service_data) return + elif data is not None and ATTR_VIDEO in data: + videos = data.get(ATTR_VIDEO, None) + videos = videos if isinstance(videos, list) else [videos] + for video_data in videos: + service_data.update(video_data) + self.hass.services.call( + DOMAIN, 'send_video', service_data=service_data) + return elif data is not None and ATTR_LOCATION in data: service_data.update(data.get(ATTR_LOCATION)) return self.hass.services.call( diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 896dbdc4399..dc9389b1144 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -65,6 +65,7 @@ DOMAIN = 'telegram_bot' SERVICE_SEND_MESSAGE = 'send_message' SERVICE_SEND_PHOTO = 'send_photo' +SERVICE_SEND_VIDEO = 'send_video' SERVICE_SEND_DOCUMENT = 'send_document' SERVICE_SEND_LOCATION = 'send_location' SERVICE_EDIT_MESSAGE = 'edit_message' @@ -154,6 +155,7 @@ SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema({ SERVICE_MAP = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_VIDEO: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, @@ -277,12 +279,11 @@ def async_setup(hass, config): if msgtype == SERVICE_SEND_MESSAGE: yield from hass.async_add_job( partial(notify_service.send_message, **kwargs)) - elif msgtype == SERVICE_SEND_PHOTO: + elif (msgtype == SERVICE_SEND_PHOTO or + msgtype == SERVICE_SEND_VIDEO or + msgtype == SERVICE_SEND_DOCUMENT): yield from hass.async_add_job( - partial(notify_service.send_file, True, **kwargs)) - elif msgtype == SERVICE_SEND_DOCUMENT: - yield from hass.async_add_job( - partial(notify_service.send_file, False, **kwargs)) + partial(notify_service.send_file, msgtype, **kwargs)) elif msgtype == SERVICE_SEND_LOCATION: yield from hass.async_add_job( partial(notify_service.send_location, **kwargs)) @@ -518,11 +519,15 @@ class TelegramNotificationService: callback_query_id, text=message, show_alert=show_alert, **params) - def send_file(self, is_photo=True, target=None, **kwargs): - """Send a photo or a document.""" + def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): + """Send a photo, video, or document.""" params = self._get_msg_kwargs(kwargs) caption = kwargs.get(ATTR_CAPTION) - func_send = self.bot.sendPhoto if is_photo else self.bot.sendDocument + func_send = { + SERVICE_SEND_PHOTO: self.bot.sendPhoto, + SERVICE_SEND_VIDEO: self.bot.sendVideo, + SERVICE_SEND_DOCUMENT: self.bot.sendDocument + }.get(file_type) file_content = load_data( self.hass, url=kwargs.get(ATTR_URL), diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 3b86d97c310..dc864c9f61a 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -59,6 +59,37 @@ send_photo: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' +send_video: + description: Send a video. + fields: + url: + description: Remote path to a video. + example: 'http://example.org/path/to/the/video.mp4' + file: + description: Local path to an image. + example: '/path/to/the/video.mp4' + caption: + description: The title of the video. + example: 'My video' + username: + description: Username for a URL which require HTTP basic authentication. + example: myuser + password: + description: Password for a URL which require HTTP basic authentication. + example: myuser_pwd + target: + description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + send_document: description: Send a document. fields: From c8648fbfb824cfa3435d2092803f9c0042e46ef2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 11 Nov 2017 15:22:05 -0800 Subject: [PATCH 076/137] Pre-construct frontend index.html (#10520) * Pre-construct frontend index.html * Cache templates * Update frontend to 20171111.0 * Fix iframe panel test --- homeassistant/components/frontend/__init__.py | 95 ++++++-------- .../components/frontend/templates/index.html | 124 ------------------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/test_panel_iframe.py | 2 +- 5 files changed, 42 insertions(+), 183 deletions(-) delete mode 100644 homeassistant/components/frontend/templates/index.html diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ba09e60b742..a656802c77d 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -13,6 +13,7 @@ from urllib.parse import urlparse from aiohttp import web import voluptuous as vol +import jinja2 import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView @@ -22,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171110.0'] +REQUIREMENTS = ['home-assistant-frontend==20171111.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http'] @@ -171,8 +172,6 @@ class BuiltInPanel(AbstractPanel): If frontend_repository_path is set, will be prepended to path of built-in components. """ - panel_path = 'panels/ha-panel-{}.html'.format(self.component_name) - if frontend_repository_path is None: import hass_frontend import hass_frontend_es5 @@ -180,11 +179,11 @@ class BuiltInPanel(AbstractPanel): self.webcomponent_url_latest = \ '/frontend_latest/panels/ha-panel-{}-{}.html'.format( self.component_name, - hass_frontend.FINGERPRINTS[panel_path]) + hass_frontend.FINGERPRINTS[self.component_name]) self.webcomponent_url_es5 = \ '/frontend_es5/panels/ha-panel-{}-{}.html'.format( self.component_name, - hass_frontend_es5.FINGERPRINTS[panel_path]) + hass_frontend_es5.FINGERPRINTS[self.component_name]) else: # Dev mode self.webcomponent_url_es5 = self.webcomponent_url_latest = \ @@ -335,7 +334,7 @@ def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(is_dev, js_version) + index_view = IndexView(repo_path, js_version) hass.http.register_view(index_view) @asyncio.coroutine @@ -435,50 +434,40 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self, use_repo, js_option): + def __init__(self, repo_path, js_option): """Initialize the frontend view.""" - from jinja2 import FileSystemLoader, Environment - - self.use_repo = use_repo - self.templates = Environment( - autoescape=True, - loader=FileSystemLoader( - os.path.join(os.path.dirname(__file__), 'templates/') - ) - ) + self.repo_path = repo_path self.js_option = js_option + self._template_cache = {} + + def get_template(self, latest): + """Get template.""" + if self.repo_path is not None: + root = self.repo_path + elif latest: + import hass_frontend + root = hass_frontend.where() + else: + import hass_frontend_es5 + root = hass_frontend_es5.where() + + tpl = self._template_cache.get(root) + + if tpl is None: + with open(os.path.join(root, 'index.html')) as file: + tpl = jinja2.Template(file.read()) + + # Cache template if not running from repository + if self.repo_path is None: + self._template_cache[root] = tpl + + return tpl @asyncio.coroutine def get(self, request, extra=None): """Serve the index view.""" hass = request.app['hass'] latest = _is_latest(self.js_option, request) - compatibility_url = None - - if self.use_repo: - core_url = '/home-assistant-polymer/{}/core.js'.format( - 'build' if latest else 'build-es5') - ui_url = '/home-assistant-polymer/src/home-assistant.html' - icons_fp = '' - icons_url = '/static/mdi.html' - else: - if latest: - import hass_frontend - core_url = '/frontend_latest/core-{}.js'.format( - hass_frontend.FINGERPRINTS['core.js']) - ui_url = '/frontend_latest/frontend-{}.html'.format( - hass_frontend.FINGERPRINTS['frontend.html']) - else: - import hass_frontend_es5 - core_url = '/frontend_es5/core-{}.js'.format( - hass_frontend_es5.FINGERPRINTS['core.js']) - compatibility_url = '/frontend_es5/compatibility-{}.js'.format( - hass_frontend_es5.FINGERPRINTS['compatibility.js']) - ui_url = '/frontend_es5/frontend-{}.html'.format( - hass_frontend_es5.FINGERPRINTS['frontend.html']) - import hass_frontend - icons_fp = '-{}'.format(hass_frontend.FINGERPRINTS['mdi.html']) - icons_url = '/static/mdi{}.html'.format(icons_fp) if request.path == '/': panel = 'states' @@ -497,23 +486,17 @@ class IndexView(HomeAssistantView): # do not try to auto connect on load no_auth = 'false' - template = yield from hass.async_add_job( - self.templates.get_template, 'index.html') + template = yield from hass.async_add_job(self.get_template, latest) - # pylint is wrong - # pylint: disable=no-member - # This is a jinja2 template, not a HA template so we call 'render'. resp = template.render( - core_url=core_url, ui_url=ui_url, - compatibility_url=compatibility_url, no_auth=no_auth, - icons_url=icons_url, icons=icons_fp, - panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=self.use_repo, + no_auth=no_auth, + panel_url=panel_url, + panels=hass.data[DATA_PANELS], + dev_mode=self.repo_path is not None, theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[DATA_EXTRA_HTML_URL], latest=latest, - service_worker_name='/service_worker.js' if latest else - '/service_worker_es5.js') + ) return web.Response(text=resp, content_type='text/html') @@ -528,8 +511,8 @@ class ManifestJSONView(HomeAssistantView): @asyncio.coroutine def get(self, request): # pylint: disable=no-self-use """Return the manifest.json.""" - msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8') - return web.Response(body=msg, content_type="application/manifest+json") + msg = json.dumps(MANIFEST_JSON, sort_keys=True) + return web.Response(text=msg, content_type="application/manifest+json") class ThemesView(HomeAssistantView): diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html deleted file mode 100644 index ae030a5d026..00000000000 --- a/homeassistant/components/frontend/templates/index.html +++ /dev/null @@ -1,124 +0,0 @@ - - - - - Home Assistant - - - - - - {% if not dev_mode %} - - {% for panel in panels.values() -%} - - {% endfor -%} - {% endif %} - - - - - - - - - - - - - -
-
- Home Assistant had trouble
connecting to the server.

- TRY AGAIN -
-
- - {# -#} - {% if not latest -%} - - {% endif -%} - - {% if not dev_mode and not latest -%} - - {% endif -%} - - - {% if panel_url -%} - - {% endif -%} - - {% for extra_url in extra_urls -%} - - {% endfor -%} - - diff --git a/requirements_all.txt b/requirements_all.txt index 9f4a911b6b1..25bed72b32b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -331,7 +331,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171110.0 +home-assistant-frontend==20171111.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 083c2792db2..7e57f0638be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171110.0 +home-assistant-frontend==20171111.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index 9a56479c469..805d73e1820 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -34,7 +34,7 @@ class TestPanelIframe(unittest.TestCase): }) @patch.dict('hass_frontend_es5.FINGERPRINTS', - {'panels/ha-panel-iframe.html': 'md5md5'}) + {'iframe': 'md5md5'}) def test_correct_config(self): """Test correct config.""" assert setup.setup_component( From 59e943b3c1f2571abac4db0fb49a1bcee744c029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Sun, 12 Nov 2017 00:57:11 +0100 Subject: [PATCH 077/137] notify.html5: use new json save and load functions (#10416) * update to use new save_json and load_json * it is no longer possible to determine if the json file contains valid or empty data. * fix lint --- homeassistant/components/notify/html5.py | 41 ++++---------- tests/components/notify/test_html5.py | 71 ++++++++++-------------- 2 files changed, 40 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index a05c061c515..2314722a2ab 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -8,7 +8,6 @@ import asyncio import datetime import json import logging -import os import time import uuid @@ -16,6 +15,8 @@ from aiohttp.hdrs import AUTHORIZATION import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.util.json import load_json, save_json +from homeassistant.exceptions import HomeAssistantError from homeassistant.components.frontend import add_manifest_json_key from homeassistant.components.http import HomeAssistantView from homeassistant.components.notify import ( @@ -125,21 +126,11 @@ def get_service(hass, config, discovery_info=None): def _load_config(filename): """Load configuration.""" - if not os.path.isfile(filename): - return {} - try: - with open(filename, 'r') as fdesc: - inp = fdesc.read() - - # In case empty file - if not inp: - return {} - - return json.loads(inp) - except (IOError, ValueError) as error: - _LOGGER.error("Reading config file %s failed: %s", filename, error) - return None + return load_json(filename) + except HomeAssistantError: + pass + return {} class JSONBytesDecoder(json.JSONEncoder): @@ -153,18 +144,6 @@ class JSONBytesDecoder(json.JSONEncoder): return json.JSONEncoder.default(self, obj) -def _save_config(filename, config): - """Save configuration.""" - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps( - config, cls=JSONBytesDecoder, indent=4, sort_keys=True)) - except (IOError, TypeError) as error: - _LOGGER.error("Saving configuration file failed: %s", error) - return False - return True - - class HTML5PushRegistrationView(HomeAssistantView): """Accepts push registrations from a browser.""" @@ -194,7 +173,7 @@ class HTML5PushRegistrationView(HomeAssistantView): self.registrations[name] = data - if not _save_config(self.json_path, self.registrations): + if not save_json(self.json_path, self.registrations): return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) @@ -223,7 +202,7 @@ class HTML5PushRegistrationView(HomeAssistantView): reg = self.registrations.pop(found) - if not _save_config(self.json_path, self.registrations): + if not save_json(self.json_path, self.registrations): self.registrations[found] = reg return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) @@ -411,8 +390,8 @@ class HTML5NotificationService(BaseNotificationService): if response.status_code == 410: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) - if not _save_config(self.registrations_json_path, - self.registrations): + if not save_json(self.registrations_json_path, + self.registrations): self.registrations[target] = reg _LOGGER.error("Error saving registration") else: diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 2c39cc5dbd7..c3998b6db64 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -57,24 +57,13 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): service = html5.get_service(hass, {}) assert service is not None - def test_get_service_with_bad_json(self): - """Test .""" - hass = MagicMock() - - m = mock_open(read_data='I am not JSON') - with patch( - 'homeassistant.components.notify.html5.open', m, create=True - ): - service = html5.get_service(hass, {}) - - assert service is None - @patch('pywebpush.WebPusher') def test_sending_message(self, mock_wp): """Test sending message.""" @@ -86,7 +75,8 @@ class TestHtml5Notify(object): m = mock_open(read_data=json.dumps(data)) with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): service = html5.get_service(hass, {'gcm_sender_id': '100'}) @@ -120,7 +110,8 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' service = html5.get_service(hass, {}) @@ -158,7 +149,8 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' service = html5.get_service(hass, {}) @@ -193,7 +185,8 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' service = html5.get_service(hass, {}) @@ -222,7 +215,7 @@ class TestHtml5Notify(object): })) assert resp.status == 400 - with patch('homeassistant.components.notify.html5._save_config', + with patch('homeassistant.components.notify.html5.save_json', return_value=False): # resp = view.post(Request(builder.get_environ())) resp = yield from client.post(REGISTER_URL, data=json.dumps({ @@ -243,14 +236,12 @@ class TestHtml5Notify(object): } m = mock_open(read_data=json.dumps(config)) - - with patch('homeassistant.components.notify.html5.open', m, - create=True): + with patch( + 'homeassistant.util.json.open', + m, create=True + ): hass.config.path.return_value = 'file.conf' - - with patch('homeassistant.components.notify.html5.os.path.isfile', - return_value=True): - service = html5.get_service(hass, {}) + service = html5.get_service(hass, {}) assert service is not None @@ -291,12 +282,11 @@ class TestHtml5Notify(object): m = mock_open(read_data=json.dumps(config)) with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' - with patch('homeassistant.components.notify.html5.os.path.isfile', - return_value=True): - service = html5.get_service(hass, {}) + service = html5.get_service(hass, {}) assert service is not None @@ -324,7 +314,7 @@ class TestHtml5Notify(object): @asyncio.coroutine def test_unregistering_device_view_handles_json_safe_error( - self, loop, test_client): + self, loop, test_client): """Test that the HTML unregister view handles JSON write errors.""" hass = MagicMock() @@ -335,12 +325,11 @@ class TestHtml5Notify(object): m = mock_open(read_data=json.dumps(config)) with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' - with patch('homeassistant.components.notify.html5.os.path.isfile', - return_value=True): - service = html5.get_service(hass, {}) + service = html5.get_service(hass, {}) assert service is not None @@ -357,7 +346,7 @@ class TestHtml5Notify(object): client = yield from test_client(app) hass.http.is_banned_ip.return_value = False - with patch('homeassistant.components.notify.html5._save_config', + with patch('homeassistant.components.notify.html5.save_json', return_value=False): resp = yield from client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_1['subscription'], @@ -375,7 +364,8 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' service = html5.get_service(hass, {}) @@ -406,17 +396,16 @@ class TestHtml5Notify(object): hass = MagicMock() data = { - 'device': SUBSCRIPTION_1, + 'device': SUBSCRIPTION_1 } m = mock_open(read_data=json.dumps(data)) with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' - with patch('homeassistant.components.notify.html5.os.path.isfile', - return_value=True): - service = html5.get_service(hass, {'gcm_sender_id': '100'}) + service = html5.get_service(hass, {'gcm_sender_id': '100'}) assert service is not None From bc23799c712aad52f0ce129aa247c08a05c83516 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 12 Nov 2017 14:25:44 +0000 Subject: [PATCH 078/137] Change to device state attributes (#10536) * Following the suggestion of @MartinHjelmare --- homeassistant/components/sensor/serial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/serial.py b/homeassistant/components/sensor/serial.py index 7bed4b25011..df0f1e21625 100644 --- a/homeassistant/components/sensor/serial.py +++ b/homeassistant/components/sensor/serial.py @@ -113,7 +113,7 @@ class SerialSensor(Entity): return False @property - def state_attributes(self): + def device_state_attributes(self): """Return the attributes of the entity (if any JSON present).""" return self._attributes From f6d511ac1a10707bf6da31e0c372695130e1db2c Mon Sep 17 00:00:00 2001 From: r4nd0mbr1ck <23737685+r4nd0mbr1ck@users.noreply.github.com> Date: Tue, 14 Nov 2017 03:32:23 +1100 Subject: [PATCH 079/137] Google Assistant request sync service (#10165) * Initial commit for request_sync functionality * Fixes for Tox results * Fixed all tox issues and tested locally with GA * Review comments - api_key, conditional read descriptions * Add test for service --- .../components/google_assistant/__init__.py | 55 ++++++++++++++++++- .../components/google_assistant/const.py | 6 ++ .../components/google_assistant/http.py | 19 +++++-- .../components/google_assistant/services.yaml | 2 + .../components/google_assistant/test_init.py | 31 +++++++++++ 5 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/google_assistant/services.yaml create mode 100644 tests/components/google_assistant/test_init.py diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 53de8764a12..2db36d8829f 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -4,9 +4,13 @@ Support for Actions on Google Assistant Smart Home Control. For more details about this component, please refer to the documentation at https://home-assistant.io/components/google_assistant/ """ +import os import asyncio import logging +import aiohttp +import async_timeout + import voluptuous as vol # Typing imports @@ -15,11 +19,16 @@ import voluptuous as vol from homeassistant.core import HomeAssistant # NOQA from typing import Dict, Any # NOQA +from homeassistant import config as conf_util from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.loader import bind_hass from .const import ( DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN, - CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS + CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, + CONF_AGENT_USER_ID, CONF_API_KEY, + SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL ) from .auth import GoogleAssistantAuthView from .http import GoogleAssistantView @@ -28,6 +37,8 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['http'] +DEFAULT_AGENT_USER_ID = 'home-assistant' + CONFIG_SCHEMA = vol.Schema( { DOMAIN: { @@ -36,17 +47,57 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean, vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list, + vol.Optional(CONF_AGENT_USER_ID, + default=DEFAULT_AGENT_USER_ID): cv.string, + vol.Optional(CONF_API_KEY): cv.string } }, extra=vol.ALLOW_EXTRA) +@bind_hass +def request_sync(hass): + """Request sync.""" + hass.services.call(DOMAIN, SERVICE_REQUEST_SYNC) + + @asyncio.coroutine def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): """Activate Google Actions component.""" config = yaml_config.get(DOMAIN, {}) - + agent_user_id = config.get(CONF_AGENT_USER_ID) + api_key = config.get(CONF_API_KEY) + if api_key is not None: + descriptions = yield from hass.async_add_job( + conf_util.load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) hass.http.register_view(GoogleAssistantAuthView(hass, config)) hass.http.register_view(GoogleAssistantView(hass, config)) + @asyncio.coroutine + def request_sync_service_handler(call): + """Handle request sync service calls.""" + websession = async_get_clientsession(hass) + try: + with async_timeout.timeout(5, loop=hass.loop): + res = yield from websession.post( + REQUEST_SYNC_BASE_URL, + params={'key': api_key}, + json={'agent_user_id': agent_user_id}) + _LOGGER.info("Submitted request_sync request to Google") + res.raise_for_status() + except aiohttp.ClientResponseError: + body = yield from res.read() + _LOGGER.error( + 'request_sync request failed: %d %s', res.status, body) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Could not contact Google for request_sync") + +# Register service only if api key is provided + if api_key is not None: + hass.services.async_register( + DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler, + descriptions.get(SERVICE_REQUEST_SYNC)) + return True diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 80afad82938..c15f14bccdb 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -13,6 +13,8 @@ CONF_PROJECT_ID = 'project_id' CONF_ACCESS_TOKEN = 'access_token' CONF_CLIENT_ID = 'client_id' CONF_ALIASES = 'aliases' +CONF_AGENT_USER_ID = 'agent_user_id' +CONF_API_KEY = 'api_key' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ @@ -44,3 +46,7 @@ TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' TYPE_SWITCH = PREFIX_TYPES + 'SWITCH' TYPE_SCENE = PREFIX_TYPES + 'SCENE' TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT' + +SERVICE_REQUEST_SYNC = 'request_sync' +HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' +REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 76b911e051a..1458d695163 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -21,10 +21,16 @@ from homeassistant.core import HomeAssistant # NOQA from homeassistant.helpers.entity import Entity # NOQA from .const import ( - CONF_ACCESS_TOKEN, CONF_EXPOSED_DOMAINS, ATTR_GOOGLE_ASSISTANT, - CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSED_DOMAINS, DEFAULT_EXPOSE_BY_DEFAULT, - GOOGLE_ASSISTANT_API_ENDPOINT) -from .smart_home import query_device, entity_to_device, determine_service + GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_ACCESS_TOKEN, + DEFAULT_EXPOSE_BY_DEFAULT, + DEFAULT_EXPOSED_DOMAINS, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + ATTR_GOOGLE_ASSISTANT, + CONF_AGENT_USER_ID + ) +from .smart_home import entity_to_device, query_device, determine_service _LOGGER = logging.getLogger(__name__) @@ -45,6 +51,7 @@ class GoogleAssistantView(HomeAssistantView): DEFAULT_EXPOSE_BY_DEFAULT) self.exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) + self.agent_user_id = cfg.get(CONF_AGENT_USER_ID) def is_entity_exposed(self, entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" @@ -82,7 +89,9 @@ class GoogleAssistantView(HomeAssistantView): devices.append(device) return self.json( - make_actions_response(request_id, {'devices': devices})) + make_actions_response(request_id, + {'agentUserId': self.agent_user_id, + 'devices': devices})) @asyncio.coroutine def handle_query(self, diff --git a/homeassistant/components/google_assistant/services.yaml b/homeassistant/components/google_assistant/services.yaml new file mode 100644 index 00000000000..6019b75bd98 --- /dev/null +++ b/homeassistant/components/google_assistant/services.yaml @@ -0,0 +1,2 @@ +request_sync: + description: Send a request_sync command to Google. \ No newline at end of file diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py new file mode 100644 index 00000000000..9ced9fc329d --- /dev/null +++ b/tests/components/google_assistant/test_init.py @@ -0,0 +1,31 @@ +"""The tests for google-assistant init.""" +import asyncio + +from homeassistant.setup import async_setup_component +from homeassistant.components import google_assistant as ga + +GA_API_KEY = "Agdgjsj399sdfkosd932ksd" +GA_AGENT_USER_ID = "testid" + + +@asyncio.coroutine +def test_request_sync_service(aioclient_mock, hass): + """Test that it posts to the request_sync url.""" + aioclient_mock.post( + ga.const.REQUEST_SYNC_BASE_URL, status=200) + + yield from async_setup_component(hass, 'google_assistant', { + 'google_assistant': { + 'project_id': 'test_project', + 'client_id': 'r7328kwdsdfsdf03223409', + 'access_token': '8wdsfjsf932492342349234', + 'agent_user_id': GA_AGENT_USER_ID, + 'api_key': GA_API_KEY + }}) + + assert aioclient_mock.call_count == 0 + yield from hass.services.async_call(ga.const.DOMAIN, + ga.const.SERVICE_REQUEST_SYNC, + blocking=True) + + assert aioclient_mock.call_count == 1 From 46fe9ed200dd4e23c6ea52a0bb637d8d3c2d2fdb Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 13 Nov 2017 18:03:12 +0100 Subject: [PATCH 080/137] Optimize concurrent access to media player image cache (#10345) We now do locking to ensure that an image is only downloaded and added once, even when requested by multiple media players at the same time. --- .../components/media_player/__init__.py | 67 +++++++++---------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index e9b51874de3..89686c312bd 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/media_player/ import asyncio from datetime import timedelta import functools as ft +import collections import hashlib import logging import os @@ -44,13 +45,14 @@ SCAN_INTERVAL = timedelta(seconds=10) ENTITY_ID_FORMAT = DOMAIN + '.{}' ENTITY_IMAGE_URL = '/api/media_player_proxy/{0}?token={1}&cache={2}' -ATTR_CACHE_IMAGES = 'images' -ATTR_CACHE_URLS = 'urls' -ATTR_CACHE_MAXSIZE = 'maxsize' +CACHE_IMAGES = 'images' +CACHE_MAXSIZE = 'maxsize' +CACHE_LOCK = 'lock' +CACHE_URL = 'url' +CACHE_CONTENT = 'content' ENTITY_IMAGE_CACHE = { - ATTR_CACHE_IMAGES: {}, - ATTR_CACHE_URLS: [], - ATTR_CACHE_MAXSIZE: 16 + CACHE_IMAGES: collections.OrderedDict(), + CACHE_MAXSIZE: 16 } SERVICE_PLAY_MEDIA = 'play_media' @@ -894,43 +896,36 @@ def _async_fetch_image(hass, url): Images are cached in memory (the images are typically 10-100kB in size). """ - cache_images = ENTITY_IMAGE_CACHE[ATTR_CACHE_IMAGES] - cache_urls = ENTITY_IMAGE_CACHE[ATTR_CACHE_URLS] - cache_maxsize = ENTITY_IMAGE_CACHE[ATTR_CACHE_MAXSIZE] + cache_images = ENTITY_IMAGE_CACHE[CACHE_IMAGES] + cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE] - if url in cache_images: - return cache_images[url] + if url not in cache_images: + cache_images[url] = {CACHE_LOCK: asyncio.Lock(loop=hass.loop)} - content, content_type = (None, None) - websession = async_get_clientsession(hass) - try: - with async_timeout.timeout(10, loop=hass.loop): - response = yield from websession.get(url) + with (yield from cache_images[url][CACHE_LOCK]): + if CACHE_CONTENT in cache_images[url]: + return cache_images[url][CACHE_CONTENT] - if response.status == 200: - content = yield from response.read() - content_type = response.headers.get(CONTENT_TYPE) - if content_type: - content_type = content_type.split(';')[0] + content, content_type = (None, None) + websession = async_get_clientsession(hass) + try: + with async_timeout.timeout(10, loop=hass.loop): + response = yield from websession.get(url) - except asyncio.TimeoutError: - pass + if response.status == 200: + content = yield from response.read() + content_type = response.headers.get(CONTENT_TYPE) + if content_type: + content_type = content_type.split(';')[0] + cache_images[url][CACHE_CONTENT] = content, content_type - if not content: - return (None, None) + except asyncio.TimeoutError: + pass - cache_images[url] = (content, content_type) - cache_urls.append(url) + while len(cache_images) > cache_maxsize: + cache_images.popitem(last=False) - while len(cache_urls) > cache_maxsize: - # remove oldest item from cache - oldest_url = cache_urls[0] - if oldest_url in cache_images: - del cache_images[oldest_url] - - cache_urls = cache_urls[1:] - - return content, content_type + return content, content_type class MediaPlayerImageView(HomeAssistantView): From a6d9c7a621e929cef244688c89b6fa6510440f02 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Mon, 13 Nov 2017 17:23:42 +0000 Subject: [PATCH 081/137] webostv: set current source correctly (#10548) --- .../components/media_player/webostv.py | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 8df8ceb0a8e..188f93da882 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -202,29 +202,25 @@ class LgWebOSDevice(MediaPlayerDevice): for app in self._client.get_apps(): self._app_list[app['id']] = app - if conf_sources: - if app['id'] == self._current_source_id: - self._current_source = app['title'] - self._source_list[app['title']] = app - elif (app['id'] in conf_sources or - any(word in app['title'] - for word in conf_sources) or - any(word in app['id'] - for word in conf_sources)): - self._source_list[app['title']] = app - else: + if app['id'] == self._current_source_id: self._current_source = app['title'] self._source_list[app['title']] = app + elif (not conf_sources or + app['id'] in conf_sources or + any(word in app['title'] + for word in conf_sources) or + any(word in app['id'] + for word in conf_sources)): + self._source_list[app['title']] = app for source in self._client.get_inputs(): - if conf_sources: - if source['id'] == self._current_source_id: - self._source_list[source['label']] = source - elif (source['label'] in conf_sources or - any(source['label'].find(word) != -1 - for word in conf_sources)): - self._source_list[source['label']] = source - else: + if source['id'] == self._current_source_id: + self._current_source = source['label'] + self._source_list[source['label']] = source + elif (not conf_sources or + source['label'] in conf_sources or + any(source['label'].find(word) != -1 + for word in conf_sources)): self._source_list[source['label']] = source except (OSError, ConnectionClosed, TypeError, asyncio.TimeoutError): From 6974f2366dfa4f01745f3b85c4f8c2aba7e392d5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 13 Nov 2017 18:24:07 +0100 Subject: [PATCH 082/137] Upgrade pysnmp to 4.4.2 (#10539) --- homeassistant/components/device_tracker/snmp.py | 8 ++++---- homeassistant/components/sensor/snmp.py | 2 +- homeassistant/components/switch/snmp.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 8c1bf6dc67b..add027e1823 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,14 +14,14 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST +REQUIREMENTS = ['pysnmp==4.4.2'] + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.4.1'] - -CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' -CONF_PRIVKEY = 'privkey' CONF_BASEOID = 'baseoid' +CONF_COMMUNITY = 'community' +CONF_PRIVKEY = 'privkey' DEFAULT_COMMUNITY = 'public' diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 841ff107826..982e7d9559b 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.4.1'] +REQUIREMENTS = ['pysnmp==4.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index d372991c3e2..99ba9d8cd54 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.1'] +REQUIREMENTS = ['pysnmp==4.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 25bed72b32b..cd042c018de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -789,7 +789,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.1 +pysnmp==4.4.2 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner From 3c135deec8f9b60aa043e88dfdf117272f1f278e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 13 Nov 2017 21:12:15 +0100 Subject: [PATCH 083/137] Fix and clean lametric (#10391) * Fix and clean lametric * Add missing DEPENDENCIES in notify platform. * Remove not needed method in component manager class. * Don't overwrite notify DOMAIN. * Return consistently depending on found devices in setup of component. * Get new token if token expired * Add debug log for getting new token * Clean up --- homeassistant/components/lametric.py | 18 +++++++----------- homeassistant/components/notify/lametric.py | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/lametric.py b/homeassistant/components/lametric.py index b11d874127f..d7c56734e42 100644 --- a/homeassistant/components/lametric.py +++ b/homeassistant/components/lametric.py @@ -38,15 +38,16 @@ def setup(hass, config): conf = config[DOMAIN] hlmn = HassLaMetricManager(client_id=conf[CONF_CLIENT_ID], client_secret=conf[CONF_CLIENT_SECRET]) - devices = hlmn.manager().get_devices() + devices = hlmn.manager.get_devices() + if not devices: + _LOGGER.error("No LaMetric devices found") + return False - found = False hass.data[DOMAIN] = hlmn for dev in devices: _LOGGER.debug("Discovered LaMetric device: %s", dev) - found = True - return found + return True class HassLaMetricManager(): @@ -63,7 +64,7 @@ class HassLaMetricManager(): from lmnotify import LaMetricManager _LOGGER.debug("Connecting to LaMetric") - self.lmn = LaMetricManager(client_id, client_secret) + self.manager = LaMetricManager(client_id, client_secret) self._client_id = client_id self._client_secret = client_secret @@ -75,9 +76,4 @@ class HassLaMetricManager(): """ from lmnotify import LaMetricManager _LOGGER.debug("Reconnecting to LaMetric") - self.lmn = LaMetricManager(self._client_id, - self._client_secret) - - def manager(self): - """Return the global LaMetricManager instance.""" - return self.lmn + self.manager = LaMetricManager(self._client_id, self._client_secret) diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index a3af1eb1914..32935419ee5 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -13,9 +13,10 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_ICON import homeassistant.helpers.config_validation as cv -from homeassistant.components.lametric import DOMAIN +from homeassistant.components.lametric import DOMAIN as LAMETRIC_DOMAIN REQUIREMENTS = ['lmnotify==0.0.4'] +DEPENDENCIES = ['lametric'] _LOGGER = logging.getLogger(__name__) @@ -30,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the Slack notification service.""" - hlmn = hass.data.get(DOMAIN) + hlmn = hass.data.get(LAMETRIC_DOMAIN) return LaMetricNotificationService(hlmn, config[CONF_ICON], config[CONF_DISPLAY_TIME] * 1000) @@ -49,6 +50,7 @@ class LaMetricNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to some LaMetric deviced.""" from lmnotify import SimpleFrame, Sound, Model + from oauthlib.oauth2 import TokenExpiredError targets = kwargs.get(ATTR_TARGET) data = kwargs.get(ATTR_DATA) @@ -82,10 +84,15 @@ class LaMetricNotificationService(BaseNotificationService): _LOGGER.debug(frames) model = Model(frames=frames) - lmn = self.hasslametricmanager.manager() - devices = lmn.get_devices() + lmn = self.hasslametricmanager.manager + try: + devices = lmn.get_devices() + except TokenExpiredError: + _LOGGER.debug("Token expired, fetching new token") + lmn.get_token() + devices = lmn.get_devices() for dev in devices: - if (targets is None) or (dev["name"] in targets): + if targets is None or dev["name"] in targets: lmn.set_device(dev) lmn.send_notification(model, lifetime=self._display_time) _LOGGER.debug("Sent notification to LaMetric %s", dev["name"]) From 2dcde12d38f65b8e4117ea531526034555662e42 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Mon, 13 Nov 2017 17:10:39 -0500 Subject: [PATCH 084/137] Support presence detection using Hitron Coda router (#9682) * Support presence detection using Hitron Coda router * at least 2 spaces before inline comment * Update hitron_coda.py * rewrote authentication code, it actually works now * make line slightly shorter to comply with hound * Removed hardcoded IP address * Fix string formatting, add timeout, and use generator * Update hitron_coda.py * Update hitron_coda.py * Update hitron_coda.py * typo * update .coveragerc * Update stale URL --- .coveragerc | 1 + .../components/device_tracker/hitron_coda.py | 138 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 homeassistant/components/device_tracker/hitron_coda.py diff --git a/.coveragerc b/.coveragerc index 390e57e2e31..4ff7fa24102 100644 --- a/.coveragerc +++ b/.coveragerc @@ -309,6 +309,7 @@ omit = homeassistant/components/device_tracker/cisco_ios.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/gpslogger.py + homeassistant/components/device_tracker/hitron_coda.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py homeassistant/components/device_tracker/keenetic_ndms2.py diff --git a/homeassistant/components/device_tracker/hitron_coda.py b/homeassistant/components/device_tracker/hitron_coda.py new file mode 100644 index 00000000000..17dc34d1040 --- /dev/null +++ b/homeassistant/components/device_tracker/hitron_coda.py @@ -0,0 +1,138 @@ +""" +Support for the Hitron CODA-4582U, provided by Rogers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.hitron_coda/ +""" +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__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = HitronCODADeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name']) + + +class HitronCODADeviceScanner(DeviceScanner): + """This class scans for devices using the CODA's web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + host = config[CONF_HOST] + self._url = 'http://{}/data/getConnectInfo.asp'.format(host) + self._loginurl = 'http://{}/goform/login'.format(host) + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + + self._userid = None + + 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 device with the given MAC address.""" + name = next(( + device.name for device in self.last_results + if device.mac == mac), None) + return name + + def _login(self): + """Log in to the router. This is required for subsequent api calls.""" + _LOGGER.info("Logging in to CODA...") + + try: + data = [ + ('user', self._username), + ('pws', self._password), + ] + res = requests.post(self._loginurl, data=data, timeout=10) + 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: + self._userid = res.cookies['userid'] + return True + except KeyError: + _LOGGER.error("Failed to log in to router") + return False + + def _update_info(self): + """Get ARP from router.""" + _LOGGER.info("Fetching...") + + if self._userid is None: + if not self._login(): + _LOGGER.error("Could not obtain a user ID from the router") + return False + last_results = [] + + # doing a request + try: + res = requests.get(self._url, timeout=10, cookies={ + 'userid': self._userid + }) + 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: + mac = info['macAddr'] + name = info['hostName'] + # 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 e33451e2b94e021e21231417807c1f0f10358c70 Mon Sep 17 00:00:00 2001 From: ziotibia81 Date: Mon, 13 Nov 2017 23:27:15 +0100 Subject: [PATCH 085/137] Better support for int types (#10409) * Better int types support * type * Added optional register order * Fix white spaces * Fix line length * Fix line too long * Fix trailing whitespace * Stylistc code fixes --- homeassistant/components/sensor/modbus.py | 68 ++++++++++++++++++----- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index 0b2198bd396..b05b58344fb 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -11,7 +11,8 @@ import voluptuous as vol import homeassistant.components.modbus as modbus from homeassistant.const import ( - CONF_NAME, CONF_OFFSET, CONF_UNIT_OF_MEASUREMENT, CONF_SLAVE) + CONF_NAME, CONF_OFFSET, CONF_UNIT_OF_MEASUREMENT, CONF_SLAVE, + CONF_STRUCTURE) from homeassistant.helpers.entity import Entity from homeassistant.helpers import config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -21,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['modbus'] CONF_COUNT = 'count' +CONF_REVERSE_ORDER = 'reverse_order' CONF_PRECISION = 'precision' CONF_REGISTER = 'register' CONF_REGISTERS = 'registers' @@ -32,7 +34,9 @@ REGISTER_TYPE_HOLDING = 'holding' REGISTER_TYPE_INPUT = 'input' DATA_TYPE_INT = 'int' +DATA_TYPE_UINT = 'uint' DATA_TYPE_FLOAT = 'float' +DATA_TYPE_CUSTOM = 'custom' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_REGISTERS): [{ @@ -41,12 +45,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): vol.In([REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT]), vol.Optional(CONF_COUNT, default=1): cv.positive_int, + vol.Optional(CONF_REVERSE_ORDER, default=False): cv.boolean, vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), vol.Optional(CONF_PRECISION, default=0): cv.positive_int, vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), vol.Optional(CONF_SLAVE): cv.positive_int, vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_INT): - vol.In([DATA_TYPE_INT, DATA_TYPE_FLOAT]), + vol.In([DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT, + DATA_TYPE_CUSTOM]), + vol.Optional(CONF_STRUCTURE): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string }] }) @@ -55,7 +62,37 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Modbus sensors.""" sensors = [] + data_types = {DATA_TYPE_INT: {1: 'h', 2: 'i', 4: 'q'}} + data_types[DATA_TYPE_UINT] = {1: 'H', 2: 'I', 4: 'Q'} + data_types[DATA_TYPE_FLOAT] = {1: 'e', 2: 'f', 4: 'd'} + for register in config.get(CONF_REGISTERS): + structure = '>i' + if register.get(CONF_DATA_TYPE) != DATA_TYPE_CUSTOM: + try: + structure = '>{:c}'.format(data_types[ + register.get(CONF_DATA_TYPE)][register.get(CONF_COUNT)]) + except KeyError: + _LOGGER.error("Unable to detect data type for %s sensor, " + "try a custom type.", register.get(CONF_NAME)) + continue + else: + structure = register.get(CONF_STRUCTURE) + + try: + size = struct.calcsize(structure) + except struct.error as err: + _LOGGER.error( + "Error in sensor %s structure: %s", + register.get(CONF_NAME), err) + continue + + if register.get(CONF_COUNT) * 2 != size: + _LOGGER.error( + "Structure size (%d bytes) mismatch registers count " + "(%d words)", size, register.get(CONF_COUNT)) + continue + sensors.append(ModbusRegisterSensor( register.get(CONF_NAME), register.get(CONF_SLAVE), @@ -63,10 +100,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): register.get(CONF_REGISTER_TYPE), register.get(CONF_UNIT_OF_MEASUREMENT), register.get(CONF_COUNT), + register.get(CONF_REVERSE_ORDER), register.get(CONF_SCALE), register.get(CONF_OFFSET), - register.get(CONF_DATA_TYPE), + structure, register.get(CONF_PRECISION))) + + if not sensors: + return False add_devices(sensors) @@ -74,8 +115,8 @@ class ModbusRegisterSensor(Entity): """Modbus register sensor.""" def __init__(self, name, slave, register, register_type, - unit_of_measurement, count, scale, offset, data_type, - precision): + unit_of_measurement, count, reverse_order, scale, offset, + structure, precision): """Initialize the modbus register sensor.""" self._name = name self._slave = int(slave) if slave else None @@ -83,10 +124,11 @@ class ModbusRegisterSensor(Entity): self._register_type = register_type self._unit_of_measurement = unit_of_measurement self._count = int(count) + self._reverse_order = reverse_order self._scale = scale self._offset = offset self._precision = precision - self._data_type = data_type + self._structure = structure self._value = None @property @@ -120,17 +162,15 @@ class ModbusRegisterSensor(Entity): try: registers = result.registers + if self._reverse_order: + registers.reverse() except AttributeError: _LOGGER.error("No response from modbus slave %s register %s", self._slave, self._register) return - if self._data_type == DATA_TYPE_FLOAT: - byte_string = b''.join( - [x.to_bytes(2, byteorder='big') for x in registers] - ) - val = struct.unpack(">f", byte_string)[0] - elif self._data_type == DATA_TYPE_INT: - for i, res in enumerate(registers): - val += res * (2**(i*16)) + byte_string = b''.join( + [x.to_bytes(2, byteorder='big') for x in registers] + ) + val = struct.unpack(self._structure, byte_string)[0] self._value = format( self._scale * val + self._offset, '.{}f'.format(self._precision)) From 7c24d7703180f32f7d19516e8de05de3df82a0e9 Mon Sep 17 00:00:00 2001 From: Kenny Millington Date: Tue, 14 Nov 2017 06:46:26 +0000 Subject: [PATCH 086/137] Don't use the 'id' field since it can be autogenerated (fixes #10551). (#10554) --- homeassistant/components/alexa/intent.py | 4 +- tests/components/alexa/test_intent.py | 63 ------------------------ 2 files changed, 1 insertion(+), 66 deletions(-) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 56887a8a701..c3a0155e312 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -155,9 +155,7 @@ class AlexaResponse(object): if 'value' not in resolved: continue - if 'id' in resolved['value']: - self.variables[underscored_key] = resolved['value']['id'] - elif 'name' in resolved['value']: + if 'name' in resolved['value']: self.variables[underscored_key] = resolved['value']['name'] def add_card(self, card_type, title, content): diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 19ecf852622..097c91ded79 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -208,69 +208,6 @@ def test_intent_request_with_slots(alexa_client): assert text == "You told us your sign is virgo." -@asyncio.coroutine -def test_intent_request_with_slots_and_id_resolution(alexa_client): - """Test a request with slots and an id synonym.""" - data = { - "version": "1.0", - "session": { - "new": False, - "sessionId": SESSION_ID, - "application": { - "applicationId": APPLICATION_ID - }, - "attributes": { - "supportedHoroscopePeriods": { - "daily": True, - "weekly": False, - "monthly": False - } - }, - "user": { - "userId": "amzn1.account.AM3B00000000000000000000000" - } - }, - "request": { - "type": "IntentRequest", - "requestId": REQUEST_ID, - "timestamp": "2015-05-13T12:34:56Z", - "intent": { - "name": "GetZodiacHoroscopeIntent", - "slots": { - "ZodiacSign": { - "name": "ZodiacSign", - "value": "virgo", - "resolutions": { - "resolutionsPerAuthority": [ - { - "authority": AUTHORITY_ID, - "status": { - "code": "ER_SUCCESS_MATCH" - }, - "values": [ - { - "value": { - "name": "Virgo", - "id": "VIRGO" - } - } - ] - } - ] - } - } - } - } - } - } - req = yield from _intent_req(alexa_client, data) - assert req.status == 200 - data = yield from req.json() - text = data.get("response", {}).get("outputSpeech", - {}).get("text") - assert text == "You told us your sign is VIRGO." - - @asyncio.coroutine def test_intent_request_with_slots_and_name_resolution(alexa_client): """Test a request with slots and a name synonym.""" From b1afed9e52681a233d3e3067b511112dc72f90ae Mon Sep 17 00:00:00 2001 From: Steve Edson Date: Tue, 14 Nov 2017 08:18:06 +0000 Subject: [PATCH 087/137] pad packets to multiple of 4 characters (#10560) * pad packets to multiple of 4 characters This fixes sending commands, see #7669 * Update broadlink.py * removed whitespace --- homeassistant/components/switch/broadlink.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index c12d13860e2..8abdba31b67 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -117,6 +117,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for packet in packets: for retry in range(DEFAULT_RETRY): try: + extra = len(packet) % 4 + if extra > 0: + packet = packet + ('=' * (4 - extra)) payload = b64decode(packet) yield from hass.async_add_job( broadlink_device.send_data, payload) From d25f6767115feae4d5cab6de74af65884f720862 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 14 Nov 2017 10:36:18 +0100 Subject: [PATCH 088/137] Move temperature display helper from components to helpers (#10555) --- homeassistant/components/climate/__init__.py | 58 +++++------- .../components/climate/eq3btsmart.py | 25 +++--- homeassistant/components/climate/wink.py | 90 ++++++++++--------- homeassistant/components/weather/__init__.py | 36 +++----- homeassistant/components/weather/demo.py | 2 +- homeassistant/const.py | 5 ++ homeassistant/helpers/temperature.py | 33 +++++++ tests/components/weather/test_weather.py | 4 +- tests/helpers/test_temperature.py | 49 ++++++++++ 9 files changed, 186 insertions(+), 116 deletions(-) create mode 100644 homeassistant/helpers/temperature.py create mode 100644 tests/helpers/test_temperature.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 61f5773356f..81a7adca1b7 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -9,12 +9,12 @@ from datetime import timedelta import logging import os import functools as ft -from numbers import Number import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass +from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.util.temperature import convert as convert_temperature from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity @@ -22,7 +22,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, - TEMP_CELSIUS) + TEMP_CELSIUS, PRECISION_WHOLE, PRECISION_TENTHS) DOMAIN = 'climate' @@ -71,11 +71,6 @@ ATTR_OPERATION_LIST = 'operation_list' ATTR_SWING_MODE = 'swing_mode' ATTR_SWING_LIST = 'swing_list' -# The degree of precision for each platform -PRECISION_WHOLE = 1 -PRECISION_HALVES = 0.5 -PRECISION_TENTHS = 0.1 - CONVERTIBLE_ATTRIBUTE = [ ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, @@ -456,12 +451,18 @@ class ClimateDevice(Entity): def state_attributes(self): """Return the optional state attributes.""" data = { - ATTR_CURRENT_TEMPERATURE: - self._convert_for_display(self.current_temperature), - ATTR_MIN_TEMP: self._convert_for_display(self.min_temp), - ATTR_MAX_TEMP: self._convert_for_display(self.max_temp), - ATTR_TEMPERATURE: - self._convert_for_display(self.target_temperature), + ATTR_CURRENT_TEMPERATURE: show_temp( + self.hass, self.current_temperature, self.temperature_unit, + self.precision), + ATTR_MIN_TEMP: show_temp( + self.hass, self.min_temp, self.temperature_unit, + self.precision), + ATTR_MAX_TEMP: show_temp( + self.hass, self.max_temp, self.temperature_unit, + self.precision), + ATTR_TEMPERATURE: show_temp( + self.hass, self.target_temperature, self.temperature_unit, + self.precision), } if self.target_temperature_step is not None: @@ -469,10 +470,12 @@ class ClimateDevice(Entity): target_temp_high = self.target_temperature_high if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + self.precision) + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + self.precision) humidity = self.target_humidity if humidity is not None: @@ -733,24 +736,3 @@ class ClimateDevice(Entity): def max_humidity(self): """Return the maximum humidity.""" return 99 - - def _convert_for_display(self, temp): - """Convert temperature into preferred units for display purposes.""" - if temp is None: - return temp - - # if the temperature is not a number this can cause issues - # with polymer components, so bail early there. - if not isinstance(temp, Number): - raise TypeError("Temperature is not a number: %s" % temp) - - if self.temperature_unit != self.unit_of_measurement: - temp = convert_temperature( - temp, self.temperature_unit, self.unit_of_measurement) - # Round in the units appropriate - if self.precision == PRECISION_HALVES: - return round(temp * 2) / 2.0 - elif self.precision == PRECISION_TENTHS: - return round(temp, 1) - # PRECISION_WHOLE as a fall back - return round(temp) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index d70890317fd..dba096bb632 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -9,12 +9,9 @@ import logging import voluptuous as vol from homeassistant.components.climate import ( - ClimateDevice, PLATFORM_SCHEMA, PRECISION_HALVES, - STATE_AUTO, STATE_ON, STATE_OFF, -) + STATE_ON, STATE_OFF, STATE_AUTO, PLATFORM_SCHEMA, ClimateDevice) from homeassistant.const import ( - CONF_MAC, TEMP_CELSIUS, CONF_DEVICES, ATTR_TEMPERATURE) - + CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['python-eq3bt==0.1.6'] @@ -58,15 +55,17 @@ class EQ3BTSmartThermostat(ClimateDevice): def __init__(self, _mac, _name): """Initialize the thermostat.""" - # we want to avoid name clash with this module.. + # We want to avoid name clash with this module. import eq3bt as eq3 - self.modes = {eq3.Mode.Open: STATE_ON, - eq3.Mode.Closed: STATE_OFF, - eq3.Mode.Auto: STATE_AUTO, - eq3.Mode.Manual: STATE_MANUAL, - eq3.Mode.Boost: STATE_BOOST, - eq3.Mode.Away: STATE_AWAY} + self.modes = { + eq3.Mode.Open: STATE_ON, + eq3.Mode.Closed: STATE_OFF, + eq3.Mode.Auto: STATE_AUTO, + eq3.Mode.Manual: STATE_MANUAL, + eq3.Mode.Boost: STATE_BOOST, + eq3.Mode.Away: STATE_AWAY, + } self.reverse_modes = {v: k for k, v in self.modes.items()} @@ -153,11 +152,11 @@ class EQ3BTSmartThermostat(ClimateDevice): def device_state_attributes(self): """Return the device specific state attributes.""" dev_specific = { + ATTR_STATE_AWAY_END: self._thermostat.away_end, ATTR_STATE_LOCKED: self._thermostat.locked, ATTR_STATE_LOW_BAT: self._thermostat.low_battery, ATTR_STATE_VALVE: self._thermostat.valve_state, ATTR_STATE_WINDOW_OPEN: self._thermostat.window_open, - ATTR_STATE_AWAY_END: self._thermostat.away_end, } return dev_specific diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index 75627f11a71..54d8d8617c7 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -4,46 +4,51 @@ Support for Wink thermostats, Air Conditioners, and Water Heaters. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ """ -import logging import asyncio +import logging -from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.components.climate import ( - STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, STATE_FAN_ONLY, - ATTR_CURRENT_HUMIDITY, STATE_ECO, STATE_ELECTRIC, - STATE_PERFORMANCE, STATE_HIGH_DEMAND, - STATE_HEAT_PUMP, STATE_GAS) + STATE_ECO, STATE_GAS, STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_ELECTRIC, + STATE_FAN_ONLY, STATE_HEAT_PUMP, ATTR_TEMPERATURE, STATE_HIGH_DEMAND, + STATE_PERFORMANCE, ATTR_TARGET_TEMP_LOW, ATTR_CURRENT_HUMIDITY, + ATTR_TARGET_TEMP_HIGH, ClimateDevice) +from homeassistant.components.wink import DOMAIN, WinkDevice from homeassistant.const import ( - TEMP_CELSIUS, STATE_ON, - STATE_OFF, STATE_UNKNOWN) + STATE_ON, STATE_OFF, TEMP_CELSIUS, STATE_UNKNOWN, PRECISION_TENTHS) +from homeassistant.helpers.temperature import display_temp as show_temp _LOGGER = logging.getLogger(__name__) +ATTR_ECO_TARGET = 'eco_target' +ATTR_EXTERNAL_TEMPERATURE = 'external_temperature' +ATTR_OCCUPIED = 'occupied' +ATTR_RHEEM_TYPE = 'rheem_type' +ATTR_SCHEDULE_ENABLED = 'schedule_enabled' +ATTR_SMART_TEMPERATURE = 'smart_temperature' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_VACATION_MODE = 'vacation_mode' + DEPENDENCIES = ['wink'] SPEED_LOW = 'low' SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' -HA_STATE_TO_WINK = {STATE_AUTO: 'auto', - STATE_ECO: 'eco', - STATE_FAN_ONLY: 'fan_only', - STATE_HEAT: 'heat_only', - STATE_COOL: 'cool_only', - STATE_PERFORMANCE: 'performance', - STATE_HIGH_DEMAND: 'high_demand', - STATE_HEAT_PUMP: 'heat_pump', - STATE_ELECTRIC: 'electric_only', - STATE_GAS: 'gas', - STATE_OFF: 'off'} -WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} +HA_STATE_TO_WINK = { + STATE_AUTO: 'auto', + STATE_COOL: 'cool_only', + STATE_ECO: 'eco', + STATE_ELECTRIC: 'electric_only', + STATE_FAN_ONLY: 'fan_only', + STATE_GAS: 'gas', + STATE_HEAT: 'heat_only', + STATE_HEAT_PUMP: 'heat_pump', + STATE_HIGH_DEMAND: 'high_demand', + STATE_OFF: 'off', + STATE_PERFORMANCE: 'performance', +} -ATTR_EXTERNAL_TEMPERATURE = "external_temperature" -ATTR_SMART_TEMPERATURE = "smart_temperature" -ATTR_ECO_TARGET = "eco_target" -ATTR_OCCUPIED = "occupied" +WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} def setup_platform(hass, config, add_devices, discovery_info=None): @@ -85,15 +90,18 @@ class WinkThermostat(WinkDevice, ClimateDevice): target_temp_high = self.target_temperature_high target_temp_low = self.target_temperature_low if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + PRECISION_TENTHS) if target_temp_low is not None: - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + PRECISION_TENTHS) if self.external_temperature: - data[ATTR_EXTERNAL_TEMPERATURE] = self._convert_for_display( - self.external_temperature) + data[ATTR_EXTERNAL_TEMPERATURE] = show_temp( + self.hass, self.external_temperature, self.temperature_unit, + PRECISION_TENTHS) if self.smart_temperature: data[ATTR_SMART_TEMPERATURE] = self.smart_temperature @@ -358,13 +366,15 @@ class WinkAC(WinkDevice, ClimateDevice): target_temp_high = self.target_temperature_high target_temp_low = self.target_temperature_low if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + PRECISION_TENTHS) if target_temp_low is not None: - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) - data["total_consumption"] = self.wink.total_consumption() - data["schedule_enabled"] = self.wink.schedule_enabled() + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + PRECISION_TENTHS) + data[ATTR_TOTAL_CONSUMPTION] = self.wink.total_consumption() + data[ATTR_SCHEDULE_ENABLED] = self.wink.schedule_enabled() return data @@ -471,8 +481,8 @@ class WinkWaterHeater(WinkDevice, ClimateDevice): def device_state_attributes(self): """Return the optional state attributes.""" data = {} - data["vacation_mode"] = self.wink.vacation_mode_enabled() - data["rheem_type"] = self.wink.rheem_type() + data[ATTR_VACATION_MODE] = self.wink.vacation_mode_enabled() + data[ATTR_RHEEM_TYPE] = self.wink.rheem_type() return data diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 9e927da893e..acb95c17814 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -6,11 +6,10 @@ https://home-assistant.io/components/weather/ """ import asyncio import logging -from numbers import Number -from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.const import PRECISION_WHOLE, PRECISION_TENTHS, TEMP_CELSIUS from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import Entity @@ -98,11 +97,19 @@ class WeatherEntity(Entity): """Return the forecast.""" return None + @property + def precision(self): + """Return the forecast.""" + return PRECISION_TENTHS if self.temperature_unit == TEMP_CELSIUS \ + else PRECISION_WHOLE + @property def state_attributes(self): """Return the state attributes.""" data = { - ATTR_WEATHER_TEMPERATURE: self._temp_for_display(self.temperature), + ATTR_WEATHER_TEMPERATURE: show_temp( + self.hass, self.temperature, self.temperature_unit, + self.precision), ATTR_WEATHER_HUMIDITY: self.humidity, } @@ -134,8 +141,9 @@ class WeatherEntity(Entity): forecast = [] for forecast_entry in self.forecast: forecast_entry = dict(forecast_entry) - forecast_entry[ATTR_FORECAST_TEMP] = self._temp_for_display( - forecast_entry[ATTR_FORECAST_TEMP]) + forecast_entry[ATTR_FORECAST_TEMP] = show_temp( + self.hass, forecast_entry[ATTR_FORECAST_TEMP], + self.temperature_unit, self.precision) forecast.append(forecast_entry) data[ATTR_FORECAST] = forecast @@ -151,19 +159,3 @@ class WeatherEntity(Entity): def condition(self): """Return the current condition.""" raise NotImplementedError() - - def _temp_for_display(self, temp): - """Convert temperature into preferred units for display purposes.""" - unit = self.temperature_unit - hass_unit = self.hass.config.units.temperature_unit - - if (temp is None or not isinstance(temp, Number) or - unit == hass_unit): - return temp - - value = convert_temperature(temp, unit, hass_unit) - - if hass_unit == TEMP_CELSIUS: - return round(value, 1) - # Users of fahrenheit generally expect integer units. - return round(value) diff --git a/homeassistant/components/weather/demo.py b/homeassistant/components/weather/demo.py index 0a404447346..02e07996213 100644 --- a/homeassistant/components/weather/demo.py +++ b/homeassistant/components/weather/demo.py @@ -31,7 +31,7 @@ CONDITION_CLASSES = { def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo weather.""" add_devices([ - DemoWeather('South', 'Sunshine', 21, 92, 1099, 0.5, TEMP_CELSIUS, + DemoWeather('South', 'Sunshine', 21.6414, 92, 1099, 0.5, TEMP_CELSIUS, [22, 19, 15, 12, 14, 18, 21]), DemoWeather('North', 'Shower rain', -12, 54, 987, 4.8, TEMP_FAHRENHEIT, [-10, -13, -18, -23, -19, -14, -9]) diff --git a/homeassistant/const.py b/homeassistant/const.py index de3f60e825f..90aa2c52483 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -417,3 +417,8 @@ SPEED_MS = 'speed_ms' # type: str ILLUMINANCE = 'illuminance' # type: str WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] + +# The degree of precision for platforms +PRECISION_WHOLE = 1 +PRECISION_HALVES = 0.5 +PRECISION_TENTHS = 0.1 diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py new file mode 100644 index 00000000000..a4626c33210 --- /dev/null +++ b/homeassistant/helpers/temperature.py @@ -0,0 +1,33 @@ +"""Temperature helpers for Home Assistant.""" +from numbers import Number + +from homeassistant.core import HomeAssistant +from homeassistant.util.temperature import convert as convert_temperature + + +def display_temp(hass: HomeAssistant, temperature: float, unit: str, + precision: float) -> float: + """Convert temperature into preferred units for display purposes.""" + temperature_unit = unit + ha_unit = hass.config.units.temperature_unit + + if temperature is None: + return temperature + + # If the temperature is not a number this can cause issues + # with Polymer components, so bail early there. + if not isinstance(temperature, Number): + raise TypeError( + "Temperature is not a number: {}".format(temperature)) + + if temperature_unit != ha_unit: + temperature = convert_temperature( + temperature, temperature_unit, ha_unit) + + # Round in the units appropriate + if precision == 0.5: + return round(temperature * 2) / 2.0 + elif precision == 0.1: + return round(temperature, 1) + # Integer as a fall back (PRECISION_WHOLE) + return round(temperature) diff --git a/tests/components/weather/test_weather.py b/tests/components/weather/test_weather.py index 1563dd377c4..9d22b1ad0ae 100644 --- a/tests/components/weather/test_weather.py +++ b/tests/components/weather/test_weather.py @@ -37,7 +37,7 @@ class TestWeather(unittest.TestCase): assert state.state == 'sunny' data = state.attributes - assert data.get(ATTR_WEATHER_TEMPERATURE) == 21 + assert data.get(ATTR_WEATHER_TEMPERATURE) == 21.6 assert data.get(ATTR_WEATHER_HUMIDITY) == 92 assert data.get(ATTR_WEATHER_PRESSURE) == 1099 assert data.get(ATTR_WEATHER_WIND_SPEED) == 0.5 @@ -57,4 +57,4 @@ class TestWeather(unittest.TestCase): assert state.state == 'rainy' data = state.attributes - assert data.get(ATTR_WEATHER_TEMPERATURE) == -24.4 + assert data.get(ATTR_WEATHER_TEMPERATURE) == -24 diff --git a/tests/helpers/test_temperature.py b/tests/helpers/test_temperature.py new file mode 100644 index 00000000000..96e7bd6c74f --- /dev/null +++ b/tests/helpers/test_temperature.py @@ -0,0 +1,49 @@ +"""Tests Home Assistant temperature helpers.""" +import unittest + +from tests.common import get_test_home_assistant + +from homeassistant.const import ( + TEMP_CELSIUS, PRECISION_WHOLE, TEMP_FAHRENHEIT, PRECISION_HALVES, + PRECISION_TENTHS) +from homeassistant.helpers.temperature import display_temp +from homeassistant.util.unit_system import METRIC_SYSTEM + +TEMP = 24.636626 + + +class TestHelpersTemperature(unittest.TestCase): + """Setup the temperature tests.""" + + def setUp(self): + """Setup the tests.""" + self.hass = get_test_home_assistant() + self.hass.config.unit_system = METRIC_SYSTEM + + def tearDown(self): + """Stop down stuff we started.""" + self.hass.stop() + + def test_temperature_not_a_number(self): + """Test that temperature is a number.""" + temp = "Temperature" + with self.assertRaises(Exception) as context: + display_temp(self.hass, temp, TEMP_CELSIUS, PRECISION_HALVES) + + self.assertTrue("Temperature is not a number: {}".format(temp) + in str(context.exception)) + + def test_celsius_halves(self): + """Test temperature to celsius rounding to halves.""" + self.assertEqual(24.5, display_temp( + self.hass, TEMP, TEMP_CELSIUS, PRECISION_HALVES)) + + def test_celsius_tenths(self): + """Test temperature to celsius rounding to tenths.""" + self.assertEqual(24.6, display_temp( + self.hass, TEMP, TEMP_CELSIUS, PRECISION_TENTHS)) + + def test_fahrenheit_wholes(self): + """Test temperature to fahrenheit rounding to wholes.""" + self.assertEqual(-4, display_temp( + self.hass, TEMP, TEMP_FAHRENHEIT, PRECISION_WHOLE)) From 637b058a7ec3e063f9e40d3443b4ec0657424ec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 14 Nov 2017 09:37:52 +0000 Subject: [PATCH 089/137] webostv: Reduce default timeout to prevent log spamming (#10564) With the default timeout of 10 seconds, the log gets filled up with "component is taking more than 10 seconds" errors. This should probably be fixed in some other way, but for now this reduces the problem a bit. --- homeassistant/components/media_player/webostv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 188f93da882..3215ad82a7c 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -57,7 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA, vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, - vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int, + vol.Optional(CONF_TIMEOUT, default=8): cv.positive_int, vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, }) From dc6e50c39d6c28628b5010b1dd07f02b31177b01 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 14 Nov 2017 10:40:44 +0100 Subject: [PATCH 090/137] Fix lametric sound (#10562) * Fix sound for lametric notify * Remove not used method --- homeassistant/components/lametric.py | 18 +----------------- homeassistant/components/notify/lametric.py | 7 +------ 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/lametric.py b/homeassistant/components/lametric.py index d7c56734e42..49b4f73ea17 100644 --- a/homeassistant/components/lametric.py +++ b/homeassistant/components/lametric.py @@ -51,13 +51,7 @@ def setup(hass, config): class HassLaMetricManager(): - """ - A class that encapsulated requests to the LaMetric manager. - - As the original class does not have a re-connect feature that is needed - for applications running for a long time as the OAuth tokens expire. This - class implements this reconnect() feature. - """ + """A class that encapsulated requests to the LaMetric manager.""" def __init__(self, client_id, client_secret): """Initialize HassLaMetricManager and connect to LaMetric.""" @@ -67,13 +61,3 @@ class HassLaMetricManager(): self.manager = LaMetricManager(client_id, client_secret) self._client_id = client_id self._client_secret = client_secret - - def reconnect(self): - """ - Reconnect to LaMetric. - - This is usually necessary when the OAuth token is expired. - """ - from lmnotify import LaMetricManager - _LOGGER.debug("Reconnecting to LaMetric") - self.manager = LaMetricManager(self._client_id, self._client_secret) diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index 32935419ee5..56030afb30c 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -78,12 +78,7 @@ class LaMetricNotificationService(BaseNotificationService): frames = [text_frame] - if sound is not None: - frames.append(sound) - - _LOGGER.debug(frames) - - model = Model(frames=frames) + model = Model(frames=frames, sound=sound) lmn = self.hasslametricmanager.manager try: devices = lmn.get_devices() From e947e6a143b7ee4ff2cc9b55eae6a0c9f19a0ccc Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Tue, 14 Nov 2017 11:41:19 +0100 Subject: [PATCH 091/137] Use a template for the Universal media player state (#10395) * Implementation of `state_template` for the Universal media_player * add tracking to entities in state template * use normal config_validation * fix tests, use defaults in platform schema, remove extra keys * and test the new option `state_template` * lint fixes * no need to check attributes against None * use `async_added_to_hass` and call `async_track_state_change` from `hass.helpers` --- .../components/media_player/universal.py | 164 +++++++---------- homeassistant/const.py | 1 + .../components/media_player/test_universal.py | 167 ++++++++++-------- 3 files changed, 155 insertions(+), 177 deletions(-) diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 9647f04f5c3..a7173e35a48 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -9,28 +9,30 @@ import logging # pylint: disable=import-error from copy import copy +import voluptuous as vol + from homeassistant.core import callback from homeassistant.components.media_player import ( - ATTR_APP_ID, ATTR_APP_NAME, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, - ATTR_MEDIA_ARTIST, ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_DURATION, ATTR_MEDIA_EPISODE, - ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, - ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, - ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION, ATTR_MEDIA_SHUFFLE, - ATTR_MEDIA_POSITION_UPDATED_AT, DOMAIN, SERVICE_PLAY_MEDIA, + ATTR_APP_ID, ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, ATTR_MEDIA_EPISODE, ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_SEASON, + ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, DOMAIN, MediaPlayerDevice, PLATFORM_SCHEMA, + SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, - SUPPORT_SHUFFLE_SET, ATTR_INPUT_SOURCE, SERVICE_SELECT_SOURCE, - SERVICE_CLEAR_PLAYLIST, MediaPlayerDevice) + SUPPORT_VOLUME_STEP) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, CONF_NAME, SERVICE_MEDIA_NEXT_TRACK, - SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, - SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, - SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, - SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, SERVICE_SHUFFLE_SET, STATE_IDLE, - STATE_OFF, STATE_ON, SERVICE_MEDIA_STOP, ATTR_SUPPORTED_FEATURES) -from homeassistant.helpers.event import async_track_state_change + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, CONF_NAME, + CONF_STATE_TEMPLATE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, + SERVICE_SHUFFLE_SET, STATE_IDLE, STATE_OFF, STATE_ON, SERVICE_MEDIA_STOP) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_call_from_config ATTR_ACTIVE_CHILD = 'active_child' @@ -48,113 +50,75 @@ OFF_STATES = [STATE_IDLE, STATE_OFF] REQUIREMENTS = [] _LOGGER = logging.getLogger(__name__) +ATTRS_SCHEMA = vol.Schema({cv.slug: cv.string}) +CMD_SCHEMA = vol.Schema({cv.slug: cv.SERVICE_SCHEMA}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_CHILDREN, default=[]): cv.entity_ids, + vol.Optional(CONF_COMMANDS, default={}): CMD_SCHEMA, + vol.Optional(CONF_ATTRS, default={}): + vol.Or(cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA), + vol.Optional(CONF_STATE_TEMPLATE): cv.template +}, extra=vol.REMOVE_EXTRA) + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the universal media players.""" - if not validate_config(config): - return - player = UniversalMediaPlayer( hass, - config[CONF_NAME], - config[CONF_CHILDREN], - config[CONF_COMMANDS], - config[CONF_ATTRS] + config.get(CONF_NAME), + config.get(CONF_CHILDREN), + config.get(CONF_COMMANDS), + config.get(CONF_ATTRS), + config.get(CONF_STATE_TEMPLATE) ) async_add_devices([player]) -def validate_config(config): - """Validate universal media player configuration.""" - del config[CONF_PLATFORM] - - # Validate name - if CONF_NAME not in config: - _LOGGER.error("Universal Media Player configuration requires name") - return False - - validate_children(config) - validate_commands(config) - validate_attributes(config) - - del_keys = [] - for key in config: - if key not in [CONF_NAME, CONF_CHILDREN, CONF_COMMANDS, CONF_ATTRS]: - _LOGGER.warning( - "Universal Media Player (%s) unrecognized parameter %s", - config[CONF_NAME], key) - del_keys.append(key) - for key in del_keys: - del config[key] - - return True - - -def validate_children(config): - """Validate children.""" - if CONF_CHILDREN not in config: - _LOGGER.info( - "No children under Universal Media Player (%s)", config[CONF_NAME]) - config[CONF_CHILDREN] = [] - elif not isinstance(config[CONF_CHILDREN], list): - _LOGGER.warning( - "Universal Media Player (%s) children not list in config. " - "They will be ignored", config[CONF_NAME]) - config[CONF_CHILDREN] = [] - - -def validate_commands(config): - """Validate commands.""" - if CONF_COMMANDS not in config: - config[CONF_COMMANDS] = {} - elif not isinstance(config[CONF_COMMANDS], dict): - _LOGGER.warning( - "Universal Media Player (%s) specified commands not dict in " - "config. They will be ignored", config[CONF_NAME]) - config[CONF_COMMANDS] = {} - - -def validate_attributes(config): - """Validate attributes.""" - if CONF_ATTRS not in config: - config[CONF_ATTRS] = {} - elif not isinstance(config[CONF_ATTRS], dict): - _LOGGER.warning( - "Universal Media Player (%s) specified attributes " - "not dict in config. They will be ignored", config[CONF_NAME]) - config[CONF_ATTRS] = {} - - for key, val in config[CONF_ATTRS].items(): - attr = val.split('|', 1) - if len(attr) == 1: - attr.append(None) - config[CONF_ATTRS][key] = attr - - class UniversalMediaPlayer(MediaPlayerDevice): """Representation of an universal media player.""" - def __init__(self, hass, name, children, commands, attributes): + def __init__(self, hass, name, children, + commands, attributes, state_template=None): """Initialize the Universal media device.""" self.hass = hass self._name = name self._children = children self._cmds = commands - self._attrs = attributes + self._attrs = {} + for key, val in attributes.items(): + attr = val.split('|', 1) + if len(attr) == 1: + attr.append(None) + self._attrs[key] = attr self._child_state = None + self._state_template = state_template + if state_template is not None: + self._state_template.hass = hass + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe to children and template state changes. + + This method must be run in the event loop and returns a coroutine. + """ @callback def async_on_dependency_update(*_): """Update ha state when dependencies update.""" self.async_schedule_update_ha_state(True) - depend = copy(children) - for entity in attributes.values(): + depend = copy(self._children) + for entity in self._attrs.values(): depend.append(entity[0]) + if self._state_template is not None: + for entity in self._state_template.extract_entities(): + depend.append(entity) - async_track_state_change(hass, depend, async_on_dependency_update) + self.hass.helpers.event.async_track_state_change( + list(set(depend)), async_on_dependency_update) def _entity_lkp(self, entity_id, state_attr=None): """Look up an entity state.""" @@ -211,6 +175,8 @@ class UniversalMediaPlayer(MediaPlayerDevice): @property def master_state(self): """Return the master state for entity or None.""" + if self._state_template is not None: + return self._state_template.async_render() if CONF_STATE in self._attrs: master_state = self._entity_lkp( self._attrs[CONF_STATE][0], self._attrs[CONF_STATE][1]) @@ -232,8 +198,8 @@ class UniversalMediaPlayer(MediaPlayerDevice): else master state or off """ master_state = self.master_state # avoid multiple lookups - if master_state == STATE_OFF: - return STATE_OFF + if (master_state == STATE_OFF) or (self._state_template is not None): + return master_state active_child = self._child_state if active_child: diff --git a/homeassistant/const.py b/homeassistant/const.py index 90aa2c52483..d08308de820 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -126,6 +126,7 @@ CONF_SHOW_ON_MAP = 'show_on_map' CONF_SLAVE = 'slave' CONF_SSL = 'ssl' CONF_STATE = 'state' +CONF_STATE_TEMPLATE = 'state_template' CONF_STRUCTURE = 'structure' CONF_SWITCHES = 'switches' CONF_TEMPERATURE_UNIT = 'temperature_unit' diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index 01281d189b4..ffd4008f385 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -2,6 +2,8 @@ from copy import copy import unittest +from voluptuous.error import MultipleInvalid + from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, STATE_PLAYING, STATE_PAUSED) import homeassistant.components.switch as switch @@ -14,6 +16,13 @@ from homeassistant.util.async import run_coroutine_threadsafe from tests.common import mock_service, get_test_home_assistant +def validate_config(config): + """Use the platform schema to validate configuration.""" + validated_config = universal.PLATFORM_SCHEMA(config) + validated_config.pop('platform') + return validated_config + + class MockMediaPlayer(media_player.MediaPlayerDevice): """Mock media player for testing.""" @@ -116,9 +125,9 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): """Mock turn_off function.""" self._state = STATE_OFF - def mute_volume(self): + def mute_volume(self, mute): """Mock mute function.""" - self._is_volume_muted = ~self._is_volume_muted + self._is_volume_muted = mute def set_volume_level(self, volume): """Mock set volume level.""" @@ -210,10 +219,8 @@ class TestMediaPlayer(unittest.TestCase): config_start['commands'] = {} config_start['attributes'] = {} - response = universal.validate_config(self.config_children_only) - - self.assertTrue(response) - self.assertEqual(config_start, self.config_children_only) + config = validate_config(self.config_children_only) + self.assertEqual(config_start, config) def test_config_children_and_attr(self): """Check config with children and attributes.""" @@ -221,15 +228,16 @@ class TestMediaPlayer(unittest.TestCase): del config_start['platform'] config_start['commands'] = {} - response = universal.validate_config(self.config_children_and_attr) - - self.assertTrue(response) - self.assertEqual(config_start, self.config_children_and_attr) + config = validate_config(self.config_children_and_attr) + self.assertEqual(config_start, config) def test_config_no_name(self): """Check config with no Name entry.""" - response = universal.validate_config({'platform': 'universal'}) - + response = True + try: + validate_config({'platform': 'universal'}) + except MultipleInvalid: + response = False self.assertFalse(response) def test_config_bad_children(self): @@ -238,36 +246,31 @@ class TestMediaPlayer(unittest.TestCase): config_bad_children = {'name': 'test', 'children': {}, 'platform': 'universal'} - response = universal.validate_config(config_no_children) - self.assertTrue(response) + config_no_children = validate_config(config_no_children) self.assertEqual([], config_no_children['children']) - response = universal.validate_config(config_bad_children) - self.assertTrue(response) + config_bad_children = validate_config(config_bad_children) self.assertEqual([], config_bad_children['children']) def test_config_bad_commands(self): """Check config with bad commands entry.""" - config = {'name': 'test', 'commands': [], 'platform': 'universal'} + config = {'name': 'test', 'platform': 'universal'} - response = universal.validate_config(config) - self.assertTrue(response) + config = validate_config(config) self.assertEqual({}, config['commands']) def test_config_bad_attributes(self): """Check config with bad attributes.""" - config = {'name': 'test', 'attributes': [], 'platform': 'universal'} + config = {'name': 'test', 'platform': 'universal'} - response = universal.validate_config(config) - self.assertTrue(response) + config = validate_config(config) self.assertEqual({}, config['attributes']) def test_config_bad_key(self): """Check config with bad key.""" config = {'name': 'test', 'asdf': 5, 'platform': 'universal'} - response = universal.validate_config(config) - self.assertTrue(response) + config = validate_config(config) self.assertFalse('asdf' in config) def test_platform_setup(self): @@ -281,21 +284,27 @@ class TestMediaPlayer(unittest.TestCase): for dev in new_entities: entities.append(dev) - run_coroutine_threadsafe( - universal.async_setup_platform(self.hass, bad_config, add_devices), - self.hass.loop).result() + setup_ok = True + try: + run_coroutine_threadsafe( + universal.async_setup_platform( + self.hass, validate_config(bad_config), add_devices), + self.hass.loop).result() + except MultipleInvalid: + setup_ok = False + self.assertFalse(setup_ok) self.assertEqual(0, len(entities)) run_coroutine_threadsafe( - universal.async_setup_platform(self.hass, config, add_devices), + universal.async_setup_platform( + self.hass, validate_config(config), add_devices), self.hass.loop).result() self.assertEqual(1, len(entities)) self.assertEqual('test', entities[0].name) def test_master_state(self): """Test master state property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -303,8 +312,7 @@ class TestMediaPlayer(unittest.TestCase): def test_master_state_with_attrs(self): """Test master state property.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -312,11 +320,26 @@ class TestMediaPlayer(unittest.TestCase): self.hass.states.set(self.mock_state_switch_id, STATE_ON) self.assertEqual(STATE_ON, ump.master_state) + def test_master_state_with_template(self): + """Test the state_template option.""" + config = copy(self.config_children_and_attr) + self.hass.states.set('input_boolean.test', STATE_OFF) + templ = '{% if states.input_boolean.test.state == "off" %}on' \ + '{% else %}{{ states.media_player.mock1.state }}{% endif %}' + config['state_template'] = templ + config = validate_config(config) + + ump = universal.UniversalMediaPlayer(self.hass, **config) + + self.assertEqual(STATE_ON, ump.master_state) + self.hass.states.set('input_boolean.test', STATE_ON) + self.assertEqual(STATE_OFF, ump.master_state) + def test_master_state_with_bad_attrs(self): """Test master state property.""" - config = self.config_children_and_attr + config = copy(self.config_children_and_attr) config['attributes']['state'] = 'bad.entity_id' - universal.validate_config(config) + config = validate_config(config) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -324,8 +347,7 @@ class TestMediaPlayer(unittest.TestCase): def test_active_child_state(self): """Test active child state property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -356,8 +378,7 @@ class TestMediaPlayer(unittest.TestCase): def test_name(self): """Test name property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -365,8 +386,7 @@ class TestMediaPlayer(unittest.TestCase): def test_polling(self): """Test should_poll property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -374,8 +394,7 @@ class TestMediaPlayer(unittest.TestCase): def test_state_children_only(self): """Test media player state with only children.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -391,8 +410,7 @@ class TestMediaPlayer(unittest.TestCase): def test_state_with_children_and_attrs(self): """Test media player with children and master state.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -416,8 +434,7 @@ class TestMediaPlayer(unittest.TestCase): def test_volume_level(self): """Test volume level property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -439,9 +456,8 @@ class TestMediaPlayer(unittest.TestCase): def test_media_image_url(self): """Test media_image_url property.""" - TEST_URL = "test_url" - config = self.config_children_only - universal.validate_config(config) + test_url = "test_url" + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -450,7 +466,7 @@ class TestMediaPlayer(unittest.TestCase): self.assertEqual(None, ump.media_image_url) self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1._media_image_url = TEST_URL + self.mock_mp_1._media_image_url = test_url self.mock_mp_1.schedule_update_ha_state() self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() @@ -460,8 +476,7 @@ class TestMediaPlayer(unittest.TestCase): def test_is_volume_muted_children_only(self): """Test is volume muted property w/ children only.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -483,8 +498,7 @@ class TestMediaPlayer(unittest.TestCase): def test_source_list_children_and_attr(self): """Test source list property w/ children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -495,8 +509,7 @@ class TestMediaPlayer(unittest.TestCase): def test_source_children_and_attr(self): """Test source property w/ children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -507,8 +520,7 @@ class TestMediaPlayer(unittest.TestCase): def test_volume_level_children_and_attr(self): """Test volume level property w/ children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -519,8 +531,7 @@ class TestMediaPlayer(unittest.TestCase): def test_is_volume_muted_children_and_attr(self): """Test is volume muted property w/ children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -531,8 +542,7 @@ class TestMediaPlayer(unittest.TestCase): def test_supported_features_children_only(self): """Test supported media commands with only children.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -549,16 +559,19 @@ class TestMediaPlayer(unittest.TestCase): def test_supported_features_children_and_cmds(self): """Test supported media commands with children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) - config['commands']['turn_on'] = 'test' - config['commands']['turn_off'] = 'test' - config['commands']['volume_up'] = 'test' - config['commands']['volume_down'] = 'test' - config['commands']['volume_mute'] = 'test' - config['commands']['volume_set'] = 'test' - config['commands']['select_source'] = 'test' - config['commands']['shuffle_set'] = 'test' + config = copy(self.config_children_and_attr) + excmd = {'service': 'media_player.test', 'data': {'entity_id': 'test'}} + config['commands'] = { + 'turn_on': excmd, + 'turn_off': excmd, + 'volume_up': excmd, + 'volume_down': excmd, + 'volume_mute': excmd, + 'volume_set': excmd, + 'select_source': excmd, + 'shuffle_set': excmd + } + config = validate_config(config) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -577,8 +590,7 @@ class TestMediaPlayer(unittest.TestCase): def test_service_call_no_active_child(self): """Test a service call to children with no active child.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -599,8 +611,7 @@ class TestMediaPlayer(unittest.TestCase): def test_service_call_to_child(self): """Test service calls that should be routed to a child.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -699,10 +710,10 @@ class TestMediaPlayer(unittest.TestCase): def test_service_call_to_command(self): """Test service call to command.""" - config = self.config_children_only + config = copy(self.config_children_only) config['commands'] = {'turn_off': { 'service': 'test.turn_off', 'data': {}}} - universal.validate_config(config) + config = validate_config(config) service = mock_service(self.hass, 'test', 'turn_off') From 061253fded1ef60da14feb59f27f712c438ebbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Bj=C3=B6rshammar?= Date: Tue, 14 Nov 2017 15:53:26 +0100 Subject: [PATCH 092/137] Verisure: Added option to set installation giid (#10504) * Added option to set installation giid * Changed where giid config var is being checked * Style fix * Fix style --- homeassistant/components/verisure.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 3ed6efc25d7..94f712896cc 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -27,6 +27,7 @@ ATTR_DEVICE_SERIAL = 'device_serial' CONF_ALARM = 'alarm' CONF_CODE_DIGITS = 'code_digits' CONF_DOOR_WINDOW = 'door_window' +CONF_GIID = 'giid' CONF_HYDROMETERS = 'hygrometers' CONF_LOCKS = 'locks' CONF_MOUSE = 'mouse' @@ -47,6 +48,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_ALARM, default=True): cv.boolean, vol.Optional(CONF_CODE_DIGITS, default=4): cv.positive_int, vol.Optional(CONF_DOOR_WINDOW, default=True): cv.boolean, + vol.Optional(CONF_GIID): cv.string, vol.Optional(CONF_HYDROMETERS, default=True): cv.boolean, vol.Optional(CONF_LOCKS, default=True): cv.boolean, vol.Optional(CONF_MOUSE, default=True): cv.boolean, @@ -110,6 +112,8 @@ class VerisureHub(object): domain_config[CONF_USERNAME], domain_config[CONF_PASSWORD]) + self.giid = domain_config.get(CONF_GIID) + import jsonpath self.jsonpath = jsonpath.jsonpath @@ -120,6 +124,8 @@ class VerisureHub(object): except self._verisure.Error as ex: _LOGGER.error('Could not log in to verisure, %s', ex) return False + if self.giid: + return self.set_giid() return True def logout(self): @@ -131,6 +137,15 @@ class VerisureHub(object): return False return True + def set_giid(self): + """Set installation GIID.""" + try: + self.session.set_giid(self.giid) + except self._verisure.Error as ex: + _LOGGER.error('Could not set installation GIID, %s', ex) + return False + return True + @Throttle(timedelta(seconds=60)) def update_overview(self): """Update the overview.""" From 95c831d5bcf5e93c8d84bca9142090888b73f18c Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Tue, 14 Nov 2017 09:56:42 -0500 Subject: [PATCH 093/137] Bump ring_doorbell to 0.1.7 (#10566) --- homeassistant/components/ring.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index 701889d60b5..c16164d7700 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['ring_doorbell==0.1.6'] +REQUIREMENTS = ['ring_doorbell==0.1.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cd042c018de..70e68e5bfa6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -951,7 +951,7 @@ restrictedpython==4.0b2 rflink==0.0.34 # homeassistant.components.ring -ring_doorbell==0.1.6 +ring_doorbell==0.1.7 # homeassistant.components.notify.rocketchat rocketchat-API==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e57f0638be..79440bf6be6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ restrictedpython==4.0b2 rflink==0.0.34 # homeassistant.components.ring -ring_doorbell==0.1.6 +ring_doorbell==0.1.7 # homeassistant.components.media_player.yamaha rxv==0.5.1 From 309e493e7697d0fbd3b0c3370c21db64786989e6 Mon Sep 17 00:00:00 2001 From: marthoc <30442019+marthoc@users.noreply.github.com> Date: Tue, 14 Nov 2017 23:19:15 -0500 Subject: [PATCH 094/137] Add code to enable discovery for mqtt cover (#10580) * Add code to enable discovery for mqtt cover * Fix pylint error --- homeassistant/components/cover/mqtt.py | 3 +++ homeassistant/components/mqtt/discovery.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index d10166a9469..0a49679b9c4 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -104,6 +104,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT Cover.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + value_template = config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 7140423633e..b6f6a1c5a92 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -20,10 +20,12 @@ 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', 'fan', 'light', 'sensor', 'switch'] +SUPPORTED_COMPONENTS = [ + 'binary_sensor', 'cover', 'fan', 'light', 'sensor', 'switch'] ALLOWED_PLATFORMS = { 'binary_sensor': ['mqtt'], + 'cover': ['mqtt'], 'fan': ['mqtt'], 'light': ['mqtt', 'mqtt_json', 'mqtt_template'], 'sensor': ['mqtt'], From 0b4de54725de83cfed5597dcc2ac0ec552c688ae Mon Sep 17 00:00:00 2001 From: Eitan Mosenkis Date: Wed, 15 Nov 2017 06:19:42 +0200 Subject: [PATCH 095/137] Google Assistant for climate entities: Support QUERY and respect system-wide unit_system setting. (#10346) --- .../components/google_assistant/http.py | 20 ++-- .../components/google_assistant/smart_home.py | 58 ++++++++--- .../google_assistant/test_google_assistant.py | 96 +++++++++++++++++++ .../google_assistant/test_smart_home.py | 26 ++++- 4 files changed, 176 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 1458d695163..71a4ff9ce3a 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -81,7 +81,7 @@ class GoogleAssistantView(HomeAssistantView): if not self.is_entity_exposed(entity): continue - device = entity_to_device(entity) + device = entity_to_device(entity, hass.config.units) if device is None: _LOGGER.warning("No mapping for %s domain", entity.domain) continue @@ -89,9 +89,9 @@ class GoogleAssistantView(HomeAssistantView): devices.append(device) return self.json( - make_actions_response(request_id, - {'agentUserId': self.agent_user_id, - 'devices': devices})) + _make_actions_response(request_id, + {'agentUserId': self.agent_user_id, + 'devices': devices})) @asyncio.coroutine def handle_query(self, @@ -112,10 +112,10 @@ class GoogleAssistantView(HomeAssistantView): # If we can't find a state, the device is offline devices[devid] = {'online': False} - devices[devid] = query_device(state) + devices[devid] = query_device(state, hass.config.units) return self.json( - make_actions_response(request_id, {'devices': devices})) + _make_actions_response(request_id, {'devices': devices})) @asyncio.coroutine def handle_execute(self, @@ -130,7 +130,8 @@ class GoogleAssistantView(HomeAssistantView): for eid in ent_ids: domain = eid.split('.')[0] (service, service_data) = determine_service( - eid, execution.get('command'), execution.get('params')) + eid, execution.get('command'), execution.get('params'), + hass.config.units) success = yield from hass.services.async_call( domain, service, service_data, blocking=True) result = {"ids": [eid], "states": {}} @@ -141,7 +142,7 @@ class GoogleAssistantView(HomeAssistantView): commands.append(result) return self.json( - make_actions_response(request_id, {'commands': commands})) + _make_actions_response(request_id, {'commands': commands})) @asyncio.coroutine def post(self, request: Request) -> Response: @@ -181,6 +182,5 @@ class GoogleAssistantView(HomeAssistantView): "invalid intent", status_code=HTTP_BAD_REQUEST) -def make_actions_response(request_id: str, payload: dict) -> dict: - """Helper to simplify format for response.""" +def _make_actions_response(request_id: str, payload: dict) -> dict: return {'requestId': request_id, 'payload': payload} diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1c8adf3d8f7..42cb555fe3c 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -5,18 +5,21 @@ import logging # pylint: disable=using-constant-test,unused-import,ungrouped-imports # if False: from aiohttp.web import Request, Response # NOQA -from typing import Dict, Tuple, Any # NOQA +from typing import Dict, Tuple, Any, Optional # NOQA from homeassistant.helpers.entity import Entity # NOQA from homeassistant.core import HomeAssistant # NOQA +from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_OFF, - SERVICE_TURN_OFF, SERVICE_TURN_ON + SERVICE_TURN_OFF, SERVICE_TURN_ON, + TEMP_FAHRENHEIT, TEMP_CELSIUS, ) from homeassistant.components import ( switch, light, cover, media_player, group, fan, scene, script, climate ) +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( ATTR_GOOGLE_ASSISTANT_NAME, ATTR_GOOGLE_ASSISTANT_TYPE, @@ -65,7 +68,7 @@ def make_actions_response(request_id: str, payload: dict) -> dict: return {'requestId': request_id, 'payload': payload} -def entity_to_device(entity: Entity): +def entity_to_device(entity: Entity, units: UnitSystem): """Convert a hass entity into an google actions device.""" class_data = MAPPING_COMPONENT.get( entity.attributes.get(ATTR_GOOGLE_ASSISTANT_TYPE) or entity.domain) @@ -105,14 +108,39 @@ def entity_to_device(entity: Entity): if m in CLIMATE_SUPPORTED_MODES) device['attributes'] = { 'availableThermostatModes': modes, - 'thermostatTemperatureUnit': 'C', + 'thermostatTemperatureUnit': + 'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C', } return device -def query_device(entity: Entity) -> dict: +def query_device(entity: Entity, units: UnitSystem) -> dict: """Take an entity and return a properly formatted device object.""" + def celsius(deg: Optional[float]) -> Optional[float]: + """Convert a float to Celsius and rounds to one decimal place.""" + if deg is None: + return None + return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1) + if entity.domain == climate.DOMAIN: + mode = entity.attributes.get(climate.ATTR_OPERATION_MODE) + if mode not in CLIMATE_SUPPORTED_MODES: + mode = 'on' + response = { + 'thermostatMode': mode, + 'thermostatTemperatureSetpoint': + celsius(entity.attributes.get(climate.ATTR_TEMPERATURE)), + 'thermostatTemperatureAmbient': + celsius(entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE)), + 'thermostatTemperatureSetpointHigh': + celsius(entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)), + 'thermostatTemperatureSetpointLow': + celsius(entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)), + 'thermostatHumidityAmbient': + entity.attributes.get(climate.ATTR_CURRENT_HUMIDITY), + } + return {k: v for k, v in response.items() if v is not None} + final_state = entity.state != STATE_OFF final_brightness = entity.attributes.get(light.ATTR_BRIGHTNESS, 255 if final_state else 0) @@ -138,8 +166,9 @@ def query_device(entity: Entity) -> dict: # erroneous bug on old pythons and pylint # https://github.com/PyCQA/pylint/issues/1212 # pylint: disable=invalid-sequence-index -def determine_service(entity_id: str, command: str, - params: dict) -> Tuple[str, dict]: +def determine_service( + entity_id: str, command: str, params: dict, + units: UnitSystem) -> Tuple[str, dict]: """ Determine service and service_data. @@ -166,14 +195,17 @@ def determine_service(entity_id: str, command: str, # special climate handling if domain == climate.DOMAIN: if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: - service_data['temperature'] = params.get( - 'thermostatTemperatureSetpoint', 25) + service_data['temperature'] = units.temperature( + params.get('thermostatTemperatureSetpoint', 25), + TEMP_CELSIUS) return (climate.SERVICE_SET_TEMPERATURE, service_data) if command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: - service_data['target_temp_high'] = params.get( - 'thermostatTemperatureSetpointHigh', 25) - service_data['target_temp_low'] = params.get( - 'thermostatTemperatureSetpointLow', 18) + service_data['target_temp_high'] = units.temperature( + params.get('thermostatTemperatureSetpointHigh', 25), + TEMP_CELSIUS) + service_data['target_temp_low'] = units.temperature( + params.get('thermostatTemperatureSetpointLow', 18), + TEMP_CELSIUS) return (climate.SERVICE_SET_TEMPERATURE, service_data) if command == COMMAND_THERMOSTAT_SET_MODE: service_data['operation_mode'] = params.get( diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 7ad59779f94..c21c63b0d52 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -11,6 +11,7 @@ from homeassistant import core, const, setup from homeassistant.components import ( fan, http, cover, light, switch, climate, async_setup, media_player) from homeassistant.components import google_assistant as ga +from homeassistant.util.unit_system import IMPERIAL_SYSTEM from . import DEMO_DEVICES @@ -198,6 +199,101 @@ def test_query_request(hass_fixture, assistant_client): assert devices['light.ceiling_lights']['brightness'] == 70 +@asyncio.coroutine +def test_query_climate_request(hass_fixture, assistant_client): + """Test a query request.""" + reqid = '5711642932632160984' + data = { + 'requestId': + reqid, + 'inputs': [{ + 'intent': 'action.devices.QUERY', + 'payload': { + 'devices': [ + {'id': 'climate.hvac'}, + {'id': 'climate.heatpump'}, + {'id': 'climate.ecobee'}, + ] + } + }] + } + result = yield from assistant_client.post( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, + data=json.dumps(data), + headers=AUTH_HEADER) + assert result.status == 200 + body = yield from result.json() + assert body.get('requestId') == reqid + devices = body['payload']['devices'] + assert devices == { + 'climate.heatpump': { + 'thermostatTemperatureSetpoint': 20.0, + 'thermostatTemperatureAmbient': 25.0, + 'thermostatMode': 'heat', + }, + 'climate.ecobee': { + 'thermostatTemperatureSetpointHigh': 24, + 'thermostatTemperatureAmbient': 23, + 'thermostatMode': 'on', + 'thermostatTemperatureSetpointLow': 21 + }, + 'climate.hvac': { + 'thermostatTemperatureSetpoint': 21, + 'thermostatTemperatureAmbient': 22, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, + } + } + + +@asyncio.coroutine +def test_query_climate_request_f(hass_fixture, assistant_client): + """Test a query request.""" + hass_fixture.config.units = IMPERIAL_SYSTEM + reqid = '5711642932632160984' + data = { + 'requestId': + reqid, + 'inputs': [{ + 'intent': 'action.devices.QUERY', + 'payload': { + 'devices': [ + {'id': 'climate.hvac'}, + {'id': 'climate.heatpump'}, + {'id': 'climate.ecobee'}, + ] + } + }] + } + result = yield from assistant_client.post( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, + data=json.dumps(data), + headers=AUTH_HEADER) + assert result.status == 200 + body = yield from result.json() + assert body.get('requestId') == reqid + devices = body['payload']['devices'] + assert devices == { + 'climate.heatpump': { + 'thermostatTemperatureSetpoint': -6.7, + 'thermostatTemperatureAmbient': -3.9, + 'thermostatMode': 'heat', + }, + 'climate.ecobee': { + 'thermostatTemperatureSetpointHigh': -4.4, + 'thermostatTemperatureAmbient': -5, + 'thermostatMode': 'on', + 'thermostatTemperatureSetpointLow': -6.1, + }, + 'climate.hvac': { + 'thermostatTemperatureSetpoint': -6.1, + 'thermostatTemperatureAmbient': -5.6, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, + } + } + + @asyncio.coroutine def test_execute_request(hass_fixture, assistant_client): """Test a execute request.""" diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 20db85b998e..6712b390dbb 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -5,6 +5,7 @@ import asyncio from homeassistant import const from homeassistant.components import climate from homeassistant.components import google_assistant as ga +from homeassistant.util.unit_system import (IMPERIAL_SYSTEM, METRIC_SYSTEM) DETERMINE_SERVICE_TESTS = [{ # Test light brightness 'entity_id': 'light.test', @@ -82,6 +83,15 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness climate.SERVICE_SET_TEMPERATURE, {'entity_id': 'climate.living_room', 'temperature': 24.5} ), +}, { # Test climate temperature Fahrenheit + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + 'params': {'thermostatTemperatureSetpoint': 24.5}, + 'units': IMPERIAL_SYSTEM, + 'expected': ( + climate.SERVICE_SET_TEMPERATURE, + {'entity_id': 'climate.living_room', 'temperature': 76.1} + ), }, { # Test climate temperature range 'entity_id': 'climate.living_room', 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, @@ -94,6 +104,19 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness {'entity_id': 'climate.living_room', 'target_temp_high': 24.5, 'target_temp_low': 20.5} ), +}, { # Test climate temperature range Fahrenheit + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + 'params': { + 'thermostatTemperatureSetpointHigh': 24.5, + 'thermostatTemperatureSetpointLow': 20.5, + }, + 'units': IMPERIAL_SYSTEM, + 'expected': ( + climate.SERVICE_SET_TEMPERATURE, + {'entity_id': 'climate.living_room', + 'target_temp_high': 76.1, 'target_temp_low': 68.9} + ), }, { # Test climate operation mode 'entity_id': 'climate.living_room', 'command': ga.const.COMMAND_THERMOSTAT_SET_MODE, @@ -122,5 +145,6 @@ def test_determine_service(): result = ga.smart_home.determine_service( test['entity_id'], test['command'], - test['params']) + test['params'], + test.get('units', METRIC_SYSTEM)) assert result == test['expected'] From 8d91de877afee16ec11e72d4d45236cffc581224 Mon Sep 17 00:00:00 2001 From: NovapaX Date: Wed, 15 Nov 2017 05:32:48 +0100 Subject: [PATCH 096/137] turn service call handler into coroutine (#10576) --- homeassistant/components/configurator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 2da8967bddf..7d1b1fd7ef1 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -207,7 +207,7 @@ class Configurator(object): self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) - @async_callback + @asyncio.coroutine def async_handle_service_call(self, call): """Handle a configure service call.""" request_id = call.data.get(ATTR_CONFIGURE_ID) @@ -220,7 +220,8 @@ class Configurator(object): # field validation goes here? if callback: - self.hass.async_add_job(callback, call.data.get(ATTR_FIELDS, {})) + yield from self.hass.async_add_job(callback, + call.data.get(ATTR_FIELDS, {})) def _generate_unique_id(self): """Generate a unique configurator ID.""" From 8111e3944c2b5d2bbbf9e147fb0bb856283fea9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Wed, 15 Nov 2017 05:35:56 +0100 Subject: [PATCH 097/137] Add basic backend support for a system log (#10492) Everything logged with "warning" or "error" is stored and exposed via the HTTP API, that can be used by the frontend. --- homeassistant/bootstrap.py | 4 +- homeassistant/components/frontend/__init__.py | 2 +- .../components/system_log/__init__.py | 145 ++++++++++++++++++ .../components/system_log/services.yaml | 3 + tests/components/test_system_log.py | 112 ++++++++++++++ 5 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/system_log/__init__.py create mode 100644 homeassistant/components/system_log/services.yaml create mode 100644 tests/components/test_system_log.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4de464be88a..64ad88f8c8b 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -30,8 +30,8 @@ ERROR_LOG_FILENAME = 'home-assistant.log' DATA_LOGGING = 'logging' FIRST_INIT_COMPONENT = set(( - 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction', - 'frontend', 'history')) + 'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', + 'introduction', 'frontend', 'history')) def from_config_dict(config: Dict[str, Any], diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a656802c77d..f6c058f977e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass REQUIREMENTS = ['home-assistant-frontend==20171111.0'] DOMAIN = 'frontend' -DEPENDENCIES = ['api', 'websocket_api', 'http'] +DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py new file mode 100644 index 00000000000..f2d3e4edad2 --- /dev/null +++ b/homeassistant/components/system_log/__init__.py @@ -0,0 +1,145 @@ +""" +Support for system log. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/system_log/ +""" +import os +import re +import asyncio +import logging +import traceback +from io import StringIO +from collections import deque + +import voluptuous as vol + +from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView + +DOMAIN = 'system_log' +DEPENDENCIES = ['http'] +SERVICE_CLEAR = 'clear' + +CONF_MAX_ENTRIES = 'max_entries' + +DEFAULT_MAX_ENTRIES = 50 + +DATA_SYSTEM_LOG = 'system_log' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MAX_ENTRIES, + default=DEFAULT_MAX_ENTRIES): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +SERVICE_CLEAR_SCHEMA = vol.Schema({}) + + +class LogErrorHandler(logging.Handler): + """Log handler for error messages.""" + + def __init__(self, maxlen): + """Initialize a new LogErrorHandler.""" + super().__init__() + self.records = deque(maxlen=maxlen) + + def emit(self, record): + """Save error and warning logs. + + Everyhing logged with error or warning is saved in local buffer. A + default upper limit is set to 50 (older entries are discarded) but can + be changed if neeeded. + """ + if record.levelno >= logging.WARN: + self.records.appendleft(record) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the logger component.""" + conf = config.get(DOMAIN) + + if conf is None: + conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + + handler = LogErrorHandler(conf.get(CONF_MAX_ENTRIES)) + logging.getLogger().addHandler(handler) + + hass.http.register_view(AllErrorsView(handler)) + yield from hass.components.frontend.async_register_built_in_panel( + 'system-log', 'system_log', 'mdi:monitor') + + @asyncio.coroutine + def async_service_handler(service): + """Handle logger services.""" + # Only one service so far + handler.records.clear() + + 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_CLEAR, async_service_handler, + descriptions[DOMAIN].get(SERVICE_CLEAR), + schema=SERVICE_CLEAR_SCHEMA) + + return True + + +def _figure_out_source(record): + # If a stack trace exists, extract filenames from the entire call stack. + # The other case is when a regular "log" is made (without an attached + # exception). In that case, just use the file where the log was made from. + if record.exc_info: + stack = [x[0] for x in traceback.extract_tb(record.exc_info[2])] + else: + stack = [record.pathname] + + # Iterate through the stack call (in reverse) and find the last call from + # a file in HA. Try to figure out where error happened. + for pathname in reversed(stack): + + # Try to match with a file within HA + match = re.match(r'.*/homeassistant/(.*)', pathname) + if match: + return match.group(1) + + # Ok, we don't know what this is + return 'unknown' + + +def _exception_as_string(exc_info): + buf = StringIO() + if exc_info: + traceback.print_exception(*exc_info, file=buf) + return buf.getvalue() + + +def _convert(record): + return { + 'timestamp': record.created, + 'level': record.levelname, + 'message': record.getMessage(), + 'exception': _exception_as_string(record.exc_info), + 'source': _figure_out_source(record), + } + + +class AllErrorsView(HomeAssistantView): + """Get all logged errors and warnings.""" + + url = "/api/error/all" + name = "api:error:all" + + def __init__(self, handler): + """Initialize a new AllErrorsView.""" + self.handler = handler + + @asyncio.coroutine + def get(self, request): + """Get all errors and warnings.""" + return self.json([_convert(x) for x in self.handler.records]) diff --git a/homeassistant/components/system_log/services.yaml b/homeassistant/components/system_log/services.yaml new file mode 100644 index 00000000000..98f86e12f8c --- /dev/null +++ b/homeassistant/components/system_log/services.yaml @@ -0,0 +1,3 @@ +system_log: + clear: + description: Clear all log entries. diff --git a/tests/components/test_system_log.py b/tests/components/test_system_log.py new file mode 100644 index 00000000000..b86c768fb42 --- /dev/null +++ b/tests/components/test_system_log.py @@ -0,0 +1,112 @@ +"""Test system log component.""" +import asyncio +import logging +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import system_log + +_LOGGER = logging.getLogger('test_logger') + + +@pytest.fixture(autouse=True) +@asyncio.coroutine +def setup_test_case(hass): + """Setup system_log component before test case.""" + config = {'system_log': {'max_entries': 2}} + yield from async_setup_component(hass, system_log.DOMAIN, config) + + +@asyncio.coroutine +def get_error_log(hass, test_client, expected_count): + """Fetch all entries from system_log via the API.""" + client = yield from test_client(hass.http.app) + resp = yield from client.get('/api/error/all') + assert resp.status == 200 + + data = yield from resp.json() + assert len(data) == expected_count + return data + + +def _generate_and_log_exception(exception, log): + try: + raise Exception(exception) + except: # pylint: disable=bare-except + _LOGGER.exception(log) + + +def assert_log(log, exception, message, level): + """Assert that specified values are in a specific log entry.""" + assert exception in log['exception'] + assert message == log['message'] + assert level == log['level'] + assert log['source'] == 'unknown' # always unkown in tests + assert 'timestamp' in log + + +@asyncio.coroutine +def test_normal_logs(hass, test_client): + """Test that debug and info are not logged.""" + _LOGGER.debug('debug') + _LOGGER.info('info') + + # Assert done by get_error_log + yield from get_error_log(hass, test_client, 0) + + +@asyncio.coroutine +def test_exception(hass, test_client): + """Test that exceptions are logged and retrieved correctly.""" + _generate_and_log_exception('exception message', 'log message') + log = (yield from get_error_log(hass, test_client, 1))[0] + assert_log(log, 'exception message', 'log message', 'ERROR') + + +@asyncio.coroutine +def test_warning(hass, test_client): + """Test that warning are logged and retrieved correctly.""" + _LOGGER.warning('warning message') + log = (yield from get_error_log(hass, test_client, 1))[0] + assert_log(log, '', 'warning message', 'WARNING') + + +@asyncio.coroutine +def test_error(hass, test_client): + """Test that errors are logged and retrieved correctly.""" + _LOGGER.error('error message') + log = (yield from get_error_log(hass, test_client, 1))[0] + assert_log(log, '', 'error message', 'ERROR') + + +@asyncio.coroutine +def test_critical(hass, test_client): + """Test that critical are logged and retrieved correctly.""" + _LOGGER.critical('critical message') + log = (yield from get_error_log(hass, test_client, 1))[0] + assert_log(log, '', 'critical message', 'CRITICAL') + + +@asyncio.coroutine +def test_remove_older_logs(hass, test_client): + """Test that older logs are rotated out.""" + _LOGGER.error('error message 1') + _LOGGER.error('error message 2') + _LOGGER.error('error message 3') + log = yield from get_error_log(hass, test_client, 2) + assert_log(log[0], '', 'error message 3', 'ERROR') + assert_log(log[1], '', 'error message 2', 'ERROR') + + +@asyncio.coroutine +def test_clear_logs(hass, test_client): + """Test that the log can be cleared via a service call.""" + _LOGGER.error('error message') + + hass.async_add_job( + hass.services.async_call( + system_log.DOMAIN, system_log.SERVICE_CLEAR, {})) + yield from hass.async_block_till_done() + + # Assert done by get_error_log + yield from get_error_log(hass, test_client, 0) From 1e493dcb8a0acdf552b71dca235c3cdca92bbe8b Mon Sep 17 00:00:00 2001 From: NovapaX Date: Wed, 15 Nov 2017 07:16:21 +0100 Subject: [PATCH 098/137] Tradfri unique identities (#10414) * Unique identity Use unique ID for generating keys and store them in config. Fallback to old id so existing installs will still work. * Remove Timeouts they don't really work. this should be fixed in pytradfri I think. * import uuid only when necessary * more selective import * lint * use load_json and save_json from util.json * remove unnecessary imports * use async configurator functions * async configurator calls * thou shalt not mixup the (a)syncs * again: no asyncs in the syncs! last warning... * Update tradfri.py --- homeassistant/components/tradfri.py | 92 ++++++++++++++--------------- 1 file changed, 45 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index ead4924d599..53ea7eac997 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -5,9 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/ikea_tradfri/ """ import asyncio -import json import logging -import os +from uuid import uuid4 import voluptuous as vol @@ -15,6 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI +from homeassistant.util.json import load_json, save_json REQUIREMENTS = ['pytradfri==4.0.1', 'DTLSSocket==0.1.4', @@ -58,26 +58,40 @@ def request_configuration(hass, config, host): """Handle the submitted configuration.""" try: from pytradfri.api.aiocoap_api import APIFactory + from pytradfri import RequestError except ImportError: _LOGGER.exception("Looks like something isn't installed!") return - api_factory = APIFactory(host, psk_id=GATEWAY_IDENTITY) - psk = yield from api_factory.generate_psk(callback_data.get('key')) - res = yield from _setup_gateway(hass, config, host, psk, + identity = uuid4().hex + security_code = callback_data.get('security_code') + + api_factory = APIFactory(host, psk_id=identity, loop=hass.loop) + # Need To Fix: currently entering a wrong security code sends + # pytradfri aiocoap API into an endless loop. + # Should just raise a requestError or something. + try: + key = yield from api_factory.generate_psk(security_code) + except RequestError: + configurator.async_notify_errors(hass, instance, + "Security Code not accepted.") + return + + res = yield from _setup_gateway(hass, config, host, identity, key, DEFAULT_ALLOW_TRADFRI_GROUPS) if not res: - hass.async_add_job(configurator.notify_errors, instance, - "Unable to connect.") + configurator.async_notify_errors(hass, instance, + "Unable to connect.") return def success(): """Set up was successful.""" - conf = _read_config(hass) - conf[host] = {'key': psk} - _write_config(hass, conf) - hass.async_add_job(configurator.request_done, instance) + conf = load_json(hass.config.path(CONFIG_FILE)) + conf[host] = {'identity': identity, + 'key': key} + save_json(hass.config.path(CONFIG_FILE), conf) + configurator.request_done(instance) hass.async_add_job(success) @@ -86,7 +100,8 @@ def request_configuration(hass, config, host): description='Please enter the security code written at the bottom of ' 'your IKEA Trådfri Gateway.', submit_caption="Confirm", - fields=[{'id': 'key', 'name': 'Security Code', 'type': 'password'}] + fields=[{'id': 'security_code', 'name': 'Security Code', + 'type': 'password'}] ) @@ -96,35 +111,37 @@ def async_setup(hass, config): conf = config.get(DOMAIN, {}) host = conf.get(CONF_HOST) allow_tradfri_groups = conf.get(CONF_ALLOW_TRADFRI_GROUPS) - keys = yield from hass.async_add_job(_read_config, hass) + known_hosts = yield from hass.async_add_job(load_json, + hass.config.path(CONFIG_FILE)) @asyncio.coroutine - def gateway_discovered(service, info): + def gateway_discovered(service, info, + allow_tradfri_groups=DEFAULT_ALLOW_TRADFRI_GROUPS): """Run when a gateway is discovered.""" host = info['host'] - if host in keys: - yield from _setup_gateway(hass, config, host, keys[host]['key'], + if host in known_hosts: + # use fallbacks for old config style + # identity was hard coded as 'homeassistant' + identity = known_hosts[host].get('identity', 'homeassistant') + key = known_hosts[host].get('key') + yield from _setup_gateway(hass, config, host, identity, key, allow_tradfri_groups) else: hass.async_add_job(request_configuration, hass, config, host) discovery.async_listen(hass, SERVICE_IKEA_TRADFRI, gateway_discovered) - if not host: - return True - - if host and keys.get(host): - return (yield from _setup_gateway(hass, config, host, - keys[host]['key'], - allow_tradfri_groups)) - else: - hass.async_add_job(request_configuration, hass, config, host) - return True + if host: + yield from gateway_discovered(None, + {'host': host}, + allow_tradfri_groups) + return True @asyncio.coroutine -def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): +def _setup_gateway(hass, hass_config, host, identity, key, + allow_tradfri_groups): """Create a gateway.""" from pytradfri import Gateway, RequestError try: @@ -134,7 +151,7 @@ def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): return False try: - factory = APIFactory(host, psk_id=GATEWAY_IDENTITY, psk=key, + factory = APIFactory(host, psk_id=identity, psk=key, loop=hass.loop) api = factory.request gateway = Gateway() @@ -163,22 +180,3 @@ def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): hass.async_add_job(discovery.async_load_platform( hass, 'sensor', DOMAIN, {'gateway': gateway_id}, hass_config)) return True - - -def _read_config(hass): - """Read tradfri config.""" - path = hass.config.path(CONFIG_FILE) - - if not os.path.isfile(path): - return {} - - with open(path) as f_handle: - # Guard against empty file - return json.loads(f_handle.read() or '{}') - - -def _write_config(hass, config): - """Write tradfri config.""" - data = json.dumps(config) - with open(hass.config.path(CONFIG_FILE), 'w', encoding='utf-8') as outfile: - outfile.write(data) From 7920ddda9d435b70edc13550fe6224895c780e8e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Nov 2017 22:39:06 -0800 Subject: [PATCH 099/137] Add panel build type (#10589) --- homeassistant/components/hassio.py | 2 +- tests/components/test_hassio.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 940de2ba12f..048a7d531f4 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -49,7 +49,7 @@ NO_TIMEOUT = { } NO_AUTH = { - re.compile(r'^panel$'), re.compile(r'^addons/[^/]*/logo$') + re.compile(r'^panel_(es5|latest)$'), re.compile(r'^addons/[^/]*/logo$') } SCHEMA_ADDON = vol.Schema({ diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index 761ba29e403..3704c486a2a 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -231,7 +231,8 @@ def test_auth_required_forward_request(hassio_client): @asyncio.coroutine -def test_forward_request_no_auth_for_panel(hassio_client): +@pytest.mark.parametrize('build_type', ['es5', 'latest']) +def test_forward_request_no_auth_for_panel(hassio_client, build_type): """Test no auth needed for .""" response = MagicMock() response.read.return_value = mock_coro('data') @@ -240,7 +241,8 @@ def test_forward_request_no_auth_for_panel(hassio_client): Mock(return_value=mock_coro(response))), \ patch('homeassistant.components.hassio._create_response') as mresp: mresp.return_value = 'response' - resp = yield from hassio_client.get('/api/hassio/panel') + resp = yield from hassio_client.get( + '/api/hassio/panel_{}'.format(build_type)) # Check we got right response assert resp.status == 200 From 0cd3271dfa9b92fd2922fb7c1ad21d1e37582d26 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Nov 2017 22:48:31 -0800 Subject: [PATCH 100/137] Update frontend to 20171115.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f6c058f977e..d1f35683e95 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171111.0'] +REQUIREMENTS = ['home-assistant-frontend==20171115.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 70e68e5bfa6..6125ee9a090 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -331,7 +331,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171111.0 +home-assistant-frontend==20171115.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79440bf6be6..ea2700f7c9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171111.0 +home-assistant-frontend==20171115.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From ea7ffff0ca43eb9e4185ab34bfd32e8b26f19d12 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Nov 2017 23:16:19 -0800 Subject: [PATCH 101/137] Cloud updates (#10567) * Update cloud * Fix tests * Lint --- homeassistant/components/cloud/__init__.py | 37 +++++++-- homeassistant/components/cloud/auth_api.py | 1 - homeassistant/components/cloud/const.py | 5 ++ homeassistant/components/cloud/http_api.py | 10 ++- homeassistant/components/cloud/iot.py | 73 +++++++++++----- tests/components/cloud/test_auth_api.py | 1 - tests/components/cloud/test_http_api.py | 44 +++++++--- tests/components/cloud/test_init.py | 42 ++++++++-- tests/components/cloud/test_iot.py | 97 ++++++++++++---------- 9 files changed, 219 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index c5d709d60c3..2d01399bc07 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,5 +1,6 @@ """Component to integrate the Home Assistant cloud.""" import asyncio +from datetime import datetime import json import logging import os @@ -8,6 +9,7 @@ import voluptuous as vol from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE) +from homeassistant.util import dt as dt_util from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS @@ -66,7 +68,6 @@ class Cloud: """Create an instance of Cloud.""" self.hass = hass self.mode = mode - self.email = None self.id_token = None self.access_token = None self.refresh_token = None @@ -89,7 +90,29 @@ class Cloud: @property def is_logged_in(self): """Get if cloud is logged in.""" - return self.email is not None + return self.id_token is not None + + @property + def subscription_expired(self): + """Return a boolen if the subscription has expired.""" + # For now, don't enforce subscriptions to exist + if 'custom:sub-exp' not in self.claims: + return False + + return dt_util.utcnow() > self.expiration_date + + @property + def expiration_date(self): + """Return the subscription expiration as a UTC datetime object.""" + return datetime.combine( + dt_util.parse_date(self.claims['custom:sub-exp']), + datetime.min.time()).replace(tzinfo=dt_util.UTC) + + @property + def claims(self): + """Get the claims from the id token.""" + from jose import jwt + return jwt.get_unverified_claims(self.id_token) @property def user_info_path(self): @@ -110,18 +133,20 @@ class Cloud: if os.path.isfile(user_info): with open(user_info, 'rt') as file: info = json.loads(file.read()) - self.email = info['email'] self.id_token = info['id_token'] self.access_token = info['access_token'] self.refresh_token = info['refresh_token'] yield from self.hass.async_add_job(load_config) - if self.email is not None: + if self.id_token is not None: yield from self.iot.connect() def path(self, *parts): - """Get config path inside cloud dir.""" + """Get config path inside cloud dir. + + Async friendly. + """ return self.hass.config.path(CONFIG_DIR, *parts) @asyncio.coroutine @@ -129,7 +154,6 @@ class Cloud: """Close connection and remove all credentials.""" yield from self.iot.disconnect() - self.email = None self.id_token = None self.access_token = None self.refresh_token = None @@ -141,7 +165,6 @@ class Cloud: """Write user info to a file.""" with open(self.user_info_path, 'wt') as file: file.write(json.dumps({ - 'email': self.email, 'id_token': self.id_token, 'access_token': self.access_token, 'refresh_token': self.refresh_token, diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 50a88d4be4d..cb9fe15ab4a 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -113,7 +113,6 @@ def login(cloud, email, password): cloud.id_token = cognito.id_token cloud.access_token = cognito.access_token cloud.refresh_token = cognito.refresh_token - cloud.email = email cloud.write_user_info() diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 334e522f81b..440e4179eea 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -12,3 +12,8 @@ SERVERS = { # 'relayer': '' # } } + +MESSAGE_EXPIRATION = """ +It looks like your Home Assistant Cloud subscription has expired. Please check +your [account page](/config/cloud/account) to continue using the service. +""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index aa91f5a45e7..d16df130c48 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -79,8 +79,10 @@ class CloudLoginView(HomeAssistantView): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job(auth_api.login, cloud, data['email'], data['password']) - hass.async_add_job(cloud.iot.connect) + hass.async_add_job(cloud.iot.connect) + # Allow cloud to start connecting. + yield from asyncio.sleep(0, loop=hass.loop) return self.json(_account_data(cloud)) @@ -222,6 +224,10 @@ class CloudConfirmForgotPasswordView(HomeAssistantView): def _account_data(cloud): """Generate the auth data JSON response.""" + claims = cloud.claims + return { - 'email': cloud.email + 'email': claims['email'], + 'sub_exp': claims.get('custom:sub-exp'), + 'cloud': cloud.iot.state, } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 1bb6668e0cc..c0b6bb96da1 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -9,11 +9,16 @@ from homeassistant.components.alexa import smart_home from homeassistant.util.decorator import Registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api +from .const import MESSAGE_EXPIRATION HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) +STATE_CONNECTING = 'connecting' +STATE_CONNECTED = 'connected' +STATE_DISCONNECTED = 'disconnected' + class UnknownHandler(Exception): """Exception raised when trying to handle unknown handler.""" @@ -25,27 +30,41 @@ class CloudIoT: def __init__(self, cloud): """Initialize the CloudIoT class.""" self.cloud = cloud + # The WebSocket client self.client = None + # Scheduled sleep task till next connection retry + self.retry_task = None + # Boolean to indicate if we wanted the connection to close self.close_requested = False + # The current number of attempts to connect, impacts wait time self.tries = 0 - - @property - def is_connected(self): - """Return if connected to the cloud.""" - return self.client is not None + # Current state of the connection + self.state = STATE_DISCONNECTED @asyncio.coroutine def connect(self): """Connect to the IoT broker.""" - if self.client is not None: - raise RuntimeError('Cannot connect while already connected') - - self.close_requested = False - hass = self.cloud.hass - remove_hass_stop_listener = None + if self.cloud.subscription_expired: + # Try refreshing the token to see if it is still expired. + yield from hass.async_add_job(auth_api.check_token, self.cloud) + if self.cloud.subscription_expired: + hass.components.persistent_notification.async_create( + MESSAGE_EXPIRATION, 'Subscription expired', + 'cloud_subscription_expired') + self.state = STATE_DISCONNECTED + return + + if self.state == STATE_CONNECTED: + raise RuntimeError('Already connected') + + self.state = STATE_CONNECTING + self.close_requested = False + remove_hass_stop_listener = None session = async_get_clientsession(self.cloud.hass) + client = None + disconnect_warn = None @asyncio.coroutine def _handle_hass_stop(event): @@ -54,8 +73,6 @@ class CloudIoT: remove_hass_stop_listener = None yield from self.disconnect() - client = None - disconnect_warn = None try: yield from hass.async_add_job(auth_api.check_token, self.cloud) @@ -70,13 +87,14 @@ class CloudIoT: EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) _LOGGER.info('Connected') + self.state = STATE_CONNECTED while not client.closed: msg = yield from client.receive() if msg.type in (WSMsgType.ERROR, WSMsgType.CLOSED, WSMsgType.CLOSING): - disconnect_warn = 'Closed by server' + disconnect_warn = 'Connection cancelled.' break elif msg.type != WSMsgType.TEXT: @@ -144,20 +162,33 @@ class CloudIoT: self.client = None yield from client.close() - if not self.close_requested: + if self.close_requested: + self.state = STATE_DISCONNECTED + + else: + self.state = STATE_CONNECTING self.tries += 1 - # Sleep 0, 5, 10, 15 … up to 30 seconds between retries - yield from asyncio.sleep( - min(30, (self.tries - 1) * 5), loop=hass.loop) - - hass.async_add_job(self.connect()) + try: + # Sleep 0, 5, 10, 15 … up to 30 seconds between retries + self.retry_task = hass.async_add_job(asyncio.sleep( + min(30, (self.tries - 1) * 5), loop=hass.loop)) + yield from self.retry_task + self.retry_task = None + hass.async_add_job(self.connect()) + except asyncio.CancelledError: + # Happens if disconnect called + pass @asyncio.coroutine def disconnect(self): """Disconnect the client.""" self.close_requested = True - yield from self.client.close() + + if self.client is not None: + yield from self.client.close() + elif self.retry_task is not None: + self.retry_task.cancel() @asyncio.coroutine diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index d9f005fdcfa..20f9265a1c1 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -69,7 +69,6 @@ def test_login(mock_cognito): auth_api.login(cloud, 'user', 'pass') assert len(mock_cognito.authenticate.mock_calls) == 1 - assert cloud.email == 'user' assert cloud.id_token == 'test_id_token' assert cloud.access_token == 'test_access_token' assert cloud.refresh_token == 'test_refresh_token' diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 1090acb01e9..296baa3f143 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -3,9 +3,10 @@ import asyncio from unittest.mock import patch, MagicMock import pytest +from jose import jwt from homeassistant.bootstrap import async_setup_component -from homeassistant.components.cloud import DOMAIN, auth_api +from homeassistant.components.cloud import DOMAIN, auth_api, iot from tests.common import mock_coro @@ -23,7 +24,8 @@ def cloud_client(hass, test_client): 'relayer': 'relayer', } })) - return hass.loop.run_until_complete(test_client(hass.http.app)) + with patch('homeassistant.components.cloud.Cloud.write_user_info'): + yield hass.loop.run_until_complete(test_client(hass.http.app)) @pytest.fixture @@ -43,21 +45,35 @@ def test_account_view_no_account(cloud_client): @asyncio.coroutine def test_account_view(hass, cloud_client): """Test fetching account if no account available.""" - hass.data[DOMAIN].email = 'hello@home-assistant.io' + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED req = yield from cloud_client.get('/api/cloud/account') assert req.status == 200 result = yield from req.json() - assert result == {'email': 'hello@home-assistant.io'} + assert result == { + 'email': 'hello@home-assistant.io', + 'sub_exp': '2018-01-03', + 'cloud': iot.STATE_CONNECTED, + } @asyncio.coroutine -def test_login_view(hass, cloud_client): +def test_login_view(hass, cloud_client, mock_cognito): """Test logging in.""" - hass.data[DOMAIN].email = 'hello@home-assistant.io' + mock_cognito.id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + mock_cognito.access_token = 'access_token' + mock_cognito.refresh_token = 'refresh_token' - with patch('homeassistant.components.cloud.iot.CloudIoT.connect'), \ - patch('homeassistant.components.cloud.' - 'auth_api.login') as mock_login: + with patch('homeassistant.components.cloud.iot.CloudIoT.' + 'connect') as mock_connect, \ + patch('homeassistant.components.cloud.auth_api._authenticate', + return_value=mock_cognito) as mock_auth: req = yield from cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' @@ -65,9 +81,13 @@ def test_login_view(hass, cloud_client): assert req.status == 200 result = yield from req.json() - assert result == {'email': 'hello@home-assistant.io'} - assert len(mock_login.mock_calls) == 1 - cloud, result_user, result_pass = mock_login.mock_calls[0][1] + assert result['email'] == 'hello@home-assistant.io' + assert result['sub_exp'] == '2018-01-03' + + assert len(mock_connect.mock_calls) == 1 + + assert len(mock_auth.mock_calls) == 1 + cloud, result_user, result_pass = mock_auth.mock_calls[0][1] assert result_user == 'my_username' assert result_pass == 'my_password' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 1eb1051520f..c05fdabf465 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -3,9 +3,11 @@ import asyncio import json from unittest.mock import patch, MagicMock, mock_open +from jose import jwt import pytest from homeassistant.components import cloud +from homeassistant.util.dt import utcnow from tests.common import mock_coro @@ -72,7 +74,6 @@ def test_initialize_loads_info(mock_os, hass): """Test initialize will load info from config file.""" mock_os.path.isfile.return_value = True mopen = mock_open(read_data=json.dumps({ - 'email': 'test-email', 'id_token': 'test-id-token', 'access_token': 'test-access-token', 'refresh_token': 'test-refresh-token', @@ -85,7 +86,6 @@ def test_initialize_loads_info(mock_os, hass): with patch('homeassistant.components.cloud.open', mopen, create=True): yield from cl.initialize() - assert cl.email == 'test-email' assert cl.id_token == 'test-id-token' assert cl.access_token == 'test-access-token' assert cl.refresh_token == 'test-refresh-token' @@ -102,7 +102,6 @@ def test_logout_clears_info(mock_os, hass): yield from cl.logout() assert len(cl.iot.disconnect.mock_calls) == 1 - assert cl.email is None assert cl.id_token is None assert cl.access_token is None assert cl.refresh_token is None @@ -115,7 +114,6 @@ def test_write_user_info(): mopen = mock_open() cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV) - cl.email = 'test-email' cl.id_token = 'test-id-token' cl.access_token = 'test-access-token' cl.refresh_token = 'test-refresh-token' @@ -129,7 +127,41 @@ def test_write_user_info(): data = json.loads(handle.write.mock_calls[0][1][0]) assert data == { 'access_token': 'test-access-token', - 'email': 'test-email', 'id_token': 'test-id-token', 'refresh_token': 'test-refresh-token', } + + +@asyncio.coroutine +def test_subscription_not_expired_without_sub_in_claim(): + """Test that we do not enforce subscriptions yet.""" + cl = cloud.Cloud(None, cloud.MODE_DEV) + cl.id_token = jwt.encode({}, 'test') + + assert not cl.subscription_expired + + +@asyncio.coroutine +def test_subscription_expired(): + """Test subscription being expired.""" + cl = cloud.Cloud(None, cloud.MODE_DEV) + cl.id_token = jwt.encode({ + 'custom:sub-exp': '2017-11-13' + }, 'test') + + with patch('homeassistant.util.dt.utcnow', + return_value=utcnow().replace(year=2018)): + assert cl.subscription_expired + + +@asyncio.coroutine +def test_subscription_not_expired(): + """Test subscription not being expired.""" + cl = cloud.Cloud(None, cloud.MODE_DEV) + cl.id_token = jwt.encode({ + 'custom:sub-exp': '2017-11-13' + }, 'test') + + with patch('homeassistant.util.dt.utcnow', + return_value=utcnow().replace(year=2017, month=11, day=9)): + assert not cl.subscription_expired diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index f1254cdb3c7..be5a93c9e47 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -30,11 +30,16 @@ def mock_handle_message(): yield mock +@pytest.fixture +def mock_cloud(): + """Mock cloud class.""" + return MagicMock(subscription_expired=False) + + @asyncio.coroutine -def test_cloud_calling_handler(mock_client, mock_handle_message): +def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud): """Test we call handle message with correct info.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ @@ -53,8 +58,8 @@ def test_cloud_calling_handler(mock_client, mock_handle_message): p_hass, p_cloud, handler_name, payload = \ mock_handle_message.mock_calls[0][1] - assert p_hass is cloud.hass - assert p_cloud is cloud + assert p_hass is mock_cloud.hass + assert p_cloud is mock_cloud assert handler_name == 'test-handler' assert payload == 'test-payload' @@ -67,10 +72,9 @@ def test_cloud_calling_handler(mock_client, mock_handle_message): @asyncio.coroutine -def test_connection_msg_for_unknown_handler(mock_client): +def test_connection_msg_for_unknown_handler(mock_client, mock_cloud): """Test a msg for an unknown handler.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ @@ -92,10 +96,10 @@ def test_connection_msg_for_unknown_handler(mock_client): @asyncio.coroutine -def test_connection_msg_for_handler_raising(mock_client, mock_handle_message): +def test_connection_msg_for_handler_raising(mock_client, mock_handle_message, + mock_cloud): """Test we sent error when handler raises exception.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ @@ -136,37 +140,34 @@ def test_handler_forwarding(): @asyncio.coroutine -def test_handling_core_messages(hass): +def test_handling_core_messages(hass, mock_cloud): """Test handling core messages.""" - cloud = MagicMock() - cloud.logout.return_value = mock_coro() - yield from iot.async_handle_cloud(hass, cloud, { + mock_cloud.logout.return_value = mock_coro() + yield from iot.async_handle_cloud(hass, mock_cloud, { 'action': 'logout', 'reason': 'Logged in at two places.' }) - assert len(cloud.logout.mock_calls) == 1 + assert len(mock_cloud.logout.mock_calls) == 1 @asyncio.coroutine -def test_cloud_getting_disconnected_by_server(mock_client, caplog): +def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): """Test server disconnecting instance.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.CLOSING, )) yield from conn.connect() - assert 'Connection closed: Closed by server' in caplog.text - assert 'connect' in str(cloud.hass.async_add_job.mock_calls[-1][1][0]) + assert 'Connection closed: Connection cancelled.' in caplog.text + assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine -def test_cloud_receiving_bytes(mock_client, caplog): +def test_cloud_receiving_bytes(mock_client, caplog, mock_cloud): """Test server disconnecting instance.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.BINARY, )) @@ -174,14 +175,13 @@ def test_cloud_receiving_bytes(mock_client, caplog): yield from conn.connect() assert 'Connection closed: Received non-Text message' in caplog.text - assert 'connect' in str(cloud.hass.async_add_job.mock_calls[-1][1][0]) + assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine -def test_cloud_sending_invalid_json(mock_client, caplog): +def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud): """Test cloud sending invalid JSON.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.TEXT, json=MagicMock(side_effect=ValueError) @@ -190,27 +190,25 @@ def test_cloud_sending_invalid_json(mock_client, caplog): yield from conn.connect() assert 'Connection closed: Received invalid JSON.' in caplog.text - assert 'connect' in str(cloud.hass.async_add_job.mock_calls[-1][1][0]) + assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine -def test_cloud_check_token_raising(mock_client, caplog): +def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): """Test cloud sending invalid JSON.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = auth_api.CloudError yield from conn.connect() assert 'Unable to connect: Unable to refresh token.' in caplog.text - assert 'connect' in str(cloud.hass.async_add_job.mock_calls[-1][1][0]) + assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine -def test_cloud_connect_invalid_auth(mock_client, caplog): +def test_cloud_connect_invalid_auth(mock_client, caplog, mock_cloud): """Test invalid auth detected by server.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = \ client_exceptions.WSServerHandshakeError(None, None, code=401) @@ -220,10 +218,9 @@ def test_cloud_connect_invalid_auth(mock_client, caplog): @asyncio.coroutine -def test_cloud_unable_to_connect(mock_client, caplog): +def test_cloud_unable_to_connect(mock_client, caplog, mock_cloud): """Test unable to connect error.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = client_exceptions.ClientError(None, None) yield from conn.connect() @@ -232,12 +229,28 @@ def test_cloud_unable_to_connect(mock_client, caplog): @asyncio.coroutine -def test_cloud_random_exception(mock_client, caplog): +def test_cloud_random_exception(mock_client, caplog, mock_cloud): """Test random exception.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = Exception yield from conn.connect() assert 'Unexpected error' in caplog.text + + +@asyncio.coroutine +def test_refresh_token_before_expiration_fails(hass, mock_cloud): + """Test that we don't connect if token is expired.""" + mock_cloud.subscription_expired = True + mock_cloud.hass = hass + conn = iot.CloudIoT(mock_cloud) + + with patch('homeassistant.components.cloud.auth_api.check_token', + return_value=mock_coro()) as mock_check_token, \ + patch.object(hass.components.persistent_notification, + 'async_create') as mock_create: + yield from conn.connect() + + assert len(mock_check_token.mock_calls) == 1 + assert len(mock_create.mock_calls) == 1 From d5b170f76166e194c1a54f658111e0d9b96e2606 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 15 Nov 2017 12:41:25 +0100 Subject: [PATCH 102/137] Upgrade youtube_dl to 2017.11.15 (#10592) --- 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 3b75c4494d8..d1f7f89863c 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -16,7 +16,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.11.06'] +REQUIREMENTS = ['youtube_dl==2017.11.15'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 6125ee9a090..c2bdad99efb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.11.06 +youtube_dl==2017.11.15 # homeassistant.components.light.zengge zengge==0.2 From c7b0f25eae0936ced42bf0183e00aee419500763 Mon Sep 17 00:00:00 2001 From: On Freund Date: Wed, 15 Nov 2017 22:27:26 +0200 Subject: [PATCH 103/137] Fix Yahoo Weather icons over SSL (#10602) --- homeassistant/components/sensor/yweather.py | 2 +- homeassistant/components/weather/yweather.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index 2883a396b77..873e27975db 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['yahooweather==0.8'] +REQUIREMENTS = ['yahooweather==0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index 12dc73af5cd..514eda0f09f 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -15,7 +15,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) from homeassistant.const import (TEMP_CELSIUS, CONF_NAME, STATE_UNKNOWN) -REQUIREMENTS = ["yahooweather==0.8"] +REQUIREMENTS = ["yahooweather==0.9"] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c2bdad99efb..dd4c97a7ea1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1148,7 +1148,7 @@ yahoo-finance==1.4.0 # homeassistant.components.sensor.yweather # homeassistant.components.weather.yweather -yahooweather==0.8 +yahooweather==0.9 # homeassistant.components.light.yeelight yeelight==0.3.3 From c2d0c8fba4cca89233f6c24d9baa5ee2b416963a Mon Sep 17 00:00:00 2001 From: Jeremy Williams Date: Wed, 15 Nov 2017 16:33:50 -0600 Subject: [PATCH 104/137] Arlo - Fixes for updated library (#9892) * Reduce update calls to API. Add signal strength monitor. * Fix lint errors * Fix indent * Update pyarlo version and review fixes * Fix lint errors * Remove staticmethod * Clean up attributes * Update arlo.py --- homeassistant/components/arlo.py | 2 +- homeassistant/components/camera/arlo.py | 35 +++++++++++-------------- homeassistant/components/sensor/arlo.py | 24 ++++++++++++++--- requirements_all.txt | 2 +- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index f3397a884d1..a78b334de0b 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -12,7 +12,7 @@ from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -REQUIREMENTS = ['pyarlo==0.0.7'] +REQUIREMENTS = ['pyarlo==0.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index be58b61fb8c..4f597771726 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -19,7 +19,7 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=10) +SCAN_INTERVAL = timedelta(seconds=90) ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' @@ -31,6 +31,7 @@ ATTR_MOTION = 'motion_detection_sensitivity' ATTR_POWERSAVE = 'power_save_mode' ATTR_SIGNAL_STRENGTH = 'signal_strength' ATTR_UNSEEN_VIDEOS = 'unseen_videos' +ATTR_LAST_REFRESH = 'last_refresh' CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' @@ -73,6 +74,8 @@ class ArloCam(Camera): self._motion_status = False self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self._last_refresh = None + self._camera.base_station.refresh_rate = SCAN_INTERVAL.total_seconds() self.attrs = {} def camera_image(self): @@ -105,14 +108,17 @@ class ArloCam(Camera): def device_state_attributes(self): """Return the state attributes.""" return { - ATTR_BATTERY_LEVEL: self.attrs.get(ATTR_BATTERY_LEVEL), - ATTR_BRIGHTNESS: self.attrs.get(ATTR_BRIGHTNESS), - ATTR_FLIPPED: self.attrs.get(ATTR_FLIPPED), - ATTR_MIRRORED: self.attrs.get(ATTR_MIRRORED), - ATTR_MOTION: self.attrs.get(ATTR_MOTION), - ATTR_POWERSAVE: self.attrs.get(ATTR_POWERSAVE), - ATTR_SIGNAL_STRENGTH: self.attrs.get(ATTR_SIGNAL_STRENGTH), - ATTR_UNSEEN_VIDEOS: self.attrs.get(ATTR_UNSEEN_VIDEOS), + name: value for name, value in ( + (ATTR_BATTERY_LEVEL, self._camera.battery_level), + (ATTR_BRIGHTNESS, self._camera.brightness), + (ATTR_FLIPPED, self._camera.flip_state), + (ATTR_MIRRORED, self._camera.mirror_state), + (ATTR_MOTION, self._camera.motion_detection_sensitivity), + (ATTR_POWERSAVE, POWERSAVE_MODE_MAPPING.get( + self._camera.powersave_mode)), + (ATTR_SIGNAL_STRENGTH, self._camera.signal_strength), + (ATTR_UNSEEN_VIDEOS, self._camera.unseen_videos), + ) if value is not None } @property @@ -160,13 +166,4 @@ class ArloCam(Camera): def update(self): """Add an attribute-update task to the executor pool.""" - self.attrs[ATTR_BATTERY_LEVEL] = self._camera.get_battery_level - self.attrs[ATTR_BRIGHTNESS] = self._camera.get_battery_level - self.attrs[ATTR_FLIPPED] = self._camera.get_flip_state, - self.attrs[ATTR_MIRRORED] = self._camera.get_mirror_state, - self.attrs[ - ATTR_MOTION] = self._camera.get_motion_detection_sensitivity, - self.attrs[ATTR_POWERSAVE] = POWERSAVE_MODE_MAPPING[ - self._camera.get_powersave_mode], - self.attrs[ATTR_SIGNAL_STRENGTH] = self._camera.get_signal_strength, - self.attrs[ATTR_UNSEEN_VIDEOS] = self._camera.unseen_videos + self._camera.update() diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py index f665d8e70ab..97b7ac22909 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -29,7 +29,8 @@ SENSOR_TYPES = { 'last_capture': ['Last', None, 'run-fast'], 'total_cameras': ['Arlo Cameras', None, 'video'], 'captured_today': ['Captured Today', None, 'file-video'], - 'battery_level': ['Battery Level', '%', 'battery-50'] + 'battery_level': ['Battery Level', '%', 'battery-50'], + 'signal_strength': ['Signal Strength', None, 'signal'] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -97,6 +98,16 @@ class ArloSensor(Entity): def update(self): """Get the latest data and updates the state.""" + try: + base_station = self._data.base_station + except (AttributeError, IndexError): + return + + if not base_station: + return + + base_station.refresh_rate = SCAN_INTERVAL.total_seconds() + self._data.update() if self._sensor_type == 'total_cameras': @@ -114,7 +125,13 @@ class ArloSensor(Entity): elif self._sensor_type == 'battery_level': try: - self._state = self._data.get_battery_level + self._state = self._data.battery_level + except TypeError: + self._state = None + + elif self._sensor_type == 'signal_strength': + try: + self._state = self._data.signal_strength except TypeError: self._state = None @@ -128,7 +145,8 @@ class ArloSensor(Entity): if self._sensor_type == 'last_capture' or \ self._sensor_type == 'captured_today' or \ - self._sensor_type == 'battery_level': + self._sensor_type == 'battery_level' or \ + self._sensor_type == 'signal_strength': attrs['model'] = self._data.model_id return attrs diff --git a/requirements_all.txt b/requirements_all.txt index dd4c97a7ea1..46f28eb5037 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ pyairvisual==1.0.0 pyalarmdotcom==0.3.0 # homeassistant.components.arlo -pyarlo==0.0.7 +pyarlo==0.1.0 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.5 From 87995ad62c35e1e58ed5c7a48d7afddcdaaeb131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Wed, 15 Nov 2017 23:45:08 +0100 Subject: [PATCH 105/137] Do not add panel from system_log (#10600) The frontend will not have this panel. --- homeassistant/components/system_log/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index f2d3e4edad2..6505107d034 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -69,8 +69,6 @@ def async_setup(hass, config): logging.getLogger().addHandler(handler) hass.http.register_view(AllErrorsView(handler)) - yield from hass.components.frontend.async_register_built_in_panel( - 'system-log', 'system_log', 'mdi:monitor') @asyncio.coroutine def async_service_handler(service): From d652d793f3f1e2d68afdf35935589f5eb487fc0b Mon Sep 17 00:00:00 2001 From: ziotibia81 Date: Thu, 16 Nov 2017 00:17:17 +0100 Subject: [PATCH 106/137] Fix ValueError exception (#10596) * Fix ValueError exception structure = '>{:c}'.format(data_types[register.get(CONF_DATA_TYPE)][register.get(CONF_COUNT)]) give: ValueError: Unknown format code 'c' for object of type 'str' * Minor typo --- homeassistant/components/sensor/modbus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index b05b58344fb..c4014fbd1dd 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -70,7 +70,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): structure = '>i' if register.get(CONF_DATA_TYPE) != DATA_TYPE_CUSTOM: try: - structure = '>{:c}'.format(data_types[ + structure = '>{}'.format(data_types[ register.get(CONF_DATA_TYPE)][register.get(CONF_COUNT)]) except KeyError: _LOGGER.error("Unable to detect data type for %s sensor, " @@ -165,7 +165,7 @@ class ModbusRegisterSensor(Entity): if self._reverse_order: registers.reverse() except AttributeError: - _LOGGER.error("No response from modbus slave %s register %s", + _LOGGER.error("No response from modbus slave %s, register %s", self._slave, self._register) return byte_string = b''.join( From 3a0c749a121eabf4175740c0c77d09e8b4931e50 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 15 Nov 2017 19:15:45 -0500 Subject: [PATCH 107/137] Fix Hikvision (motion) switch bug (#10608) * Fix Hikvision switch bug * Added comment about last working version --- homeassistant/components/switch/hikvisioncam.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/hikvisioncam.py b/homeassistant/components/switch/hikvisioncam.py index acb9af3cacb..c3e065abc0e 100644 --- a/homeassistant/components/switch/hikvisioncam.py +++ b/homeassistant/components/switch/hikvisioncam.py @@ -15,7 +15,8 @@ from homeassistant.const import ( from homeassistant.helpers.entity import ToggleEntity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['hikvision==1.2'] +REQUIREMENTS = ['hikvision==0.4'] +# This is the last working version, please test before updating _LOGGING = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 46f28eb5037..975e6359431 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -322,7 +322,7 @@ hbmqtt==0.8 heatmiserV3==0.9.1 # homeassistant.components.switch.hikvisioncam -hikvision==1.2 +hikvision==0.4 # homeassistant.components.notify.hipchat hipnotify==1.0.8 From d5cba0b716191ce4f40d70f7f6b7408f6bae68c7 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 16 Nov 2017 04:24:08 +0200 Subject: [PATCH 108/137] Allow unicode when dumping yaml (#10607) --- homeassistant/util/yaml.py | 3 ++- tests/util/test_yaml.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index da97ed5662e..48d709bc549 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -78,7 +78,8 @@ def load_yaml(fname: str) -> Union[List, Dict]: def dump(_dict: dict) -> str: """Dump YAML to a string and remove null.""" - return yaml.safe_dump(_dict, default_flow_style=False) \ + return yaml.safe_dump( + _dict, default_flow_style=False, allow_unicode=True) \ .replace(': null\n', ':\n') diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 50e271008a2..38b957ad102 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -267,6 +267,10 @@ class TestYaml(unittest.TestCase): """The that the dump method returns empty None values.""" assert yaml.dump({'a': None, 'b': 'b'}) == 'a:\nb: b\n' + def test_dump_unicode(self): + """The that the dump method returns empty None values.""" + assert yaml.dump({'a': None, 'b': 'привет'}) == 'a:\nb: привет\n' + FILES = {} From 48181a9388612fd678e47f94bce880dfd7ec9727 Mon Sep 17 00:00:00 2001 From: Michael Chang Date: Wed, 15 Nov 2017 23:44:27 -0600 Subject: [PATCH 109/137] Support script execution for Alexa (#10517) * Support script execution for Alexa * Use PowerController for the script component --- homeassistant/components/alexa/smart_home.py | 3 ++- tests/components/alexa/test_smart_home.py | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index e65345cabca..a96386cbdf9 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -6,7 +6,7 @@ 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 +from homeassistant.components import switch, light, script import homeassistant.util.color as color_util from homeassistant.util.decorator import Registry @@ -21,6 +21,7 @@ API_ENDPOINT = 'endpoint' MAPPING_COMPONENT = { + script.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], light.DOMAIN: [ 'LIGHT', ('Alexa.PowerController',), { diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 4c79e95b324..eadb72f91c0 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -114,12 +114,15 @@ def test_discovery_request(hass): 'friendly_name': "Test light 3", 'supported_features': 19 }) + hass.states.async_set( + 'script.test', 'off', {'friendly_name': "Test script"}) + msg = yield from smart_home.async_handle_message(hass, request) assert 'event' in msg msg = msg['event'] - assert len(msg['payload']['endpoints']) == 4 + assert len(msg['payload']['endpoints']) == 5 assert msg['header']['name'] == 'Discover.Response' assert msg['header']['namespace'] == 'Alexa.Discovery' @@ -170,6 +173,14 @@ def test_discovery_request(hass): continue + if appliance['endpointId'] == 'script#test': + assert appliance['displayCategories'][0] == "SWITCH" + assert appliance['friendlyName'] == "Test script" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + raise AssertionError("Unknown appliance!") @@ -206,7 +217,7 @@ def test_api_function_not_implemented(hass): @asyncio.coroutine -@pytest.mark.parametrize("domain", ['light', 'switch']) +@pytest.mark.parametrize("domain", ['light', 'switch', 'script']) def test_api_turn_on(hass, domain): """Test api turn on process.""" request = get_new_request( @@ -231,7 +242,7 @@ def test_api_turn_on(hass, domain): @asyncio.coroutine -@pytest.mark.parametrize("domain", ['light', 'switch']) +@pytest.mark.parametrize("domain", ['light', 'switch', 'script']) def test_api_turn_off(hass, domain): """Test api turn on process.""" request = get_new_request( From 17cd64966dcc68274f99318ae958e57b134b755a Mon Sep 17 00:00:00 2001 From: "Craig J. Ward" Date: Thu, 16 Nov 2017 00:04:26 -0600 Subject: [PATCH 110/137] bump client version (#10610) --- homeassistant/components/alarm_control_panel/totalconnect.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 7abdf5efcab..423628c9365 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) -REQUIREMENTS = ['total_connect_client==0.12'] +REQUIREMENTS = ['total_connect_client==0.13'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 975e6359431..29efb610897 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ todoist-python==7.0.17 toonlib==1.0.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.12 +total_connect_client==0.13 # homeassistant.components.sensor.transmission # homeassistant.components.switch.transmission From b2ab4443a7ab576b6c882861211c52a60dfe7509 Mon Sep 17 00:00:00 2001 From: Fabrizio Furnari Date: Thu, 16 Nov 2017 07:07:16 +0100 Subject: [PATCH 111/137] New sensor viaggiatreno. (#10522) * New sensor viaggiatreno. I've messed up the previous PR so here it is in a new one. Should include also all corrections from @pvizeli * fixes from PR 10522 * fixed import order * requested changes from MartinHjelmare --- .coveragerc | 1 + .../components/sensor/viaggiatreno.py | 187 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 homeassistant/components/sensor/viaggiatreno.py diff --git a/.coveragerc b/.coveragerc index 4ff7fa24102..01187b92d66 100644 --- a/.coveragerc +++ b/.coveragerc @@ -583,6 +583,7 @@ omit = homeassistant/components/sensor/upnp.py homeassistant/components/sensor/ups.py homeassistant/components/sensor/vasttrafik.py + homeassistant/components/sensor/viaggiatreno.py homeassistant/components/sensor/waqi.py homeassistant/components/sensor/whois.py homeassistant/components/sensor/worldtidesinfo.py diff --git a/homeassistant/components/sensor/viaggiatreno.py b/homeassistant/components/sensor/viaggiatreno.py new file mode 100644 index 00000000000..37e7e020cc9 --- /dev/null +++ b/homeassistant/components/sensor/viaggiatreno.py @@ -0,0 +1,187 @@ +""" +Support for information about the Italian train system using ViaggiaTreno API. + +For more details about this platform please refer to the documentation at +https://home-assistant.io/components/sensor.viaggiatreno +""" +import logging + +import asyncio +import async_timeout +import aiohttp + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Powered by ViaggiaTreno Data" +VIAGGIATRENO_ENDPOINT = ("http://www.viaggiatreno.it/viaggiatrenonew/" + "resteasy/viaggiatreno/andamentoTreno/" + "{station_id}/{train_id}") + +REQUEST_TIMEOUT = 5 # seconds +ICON = 'mdi:train' +MONITORED_INFO = [ + 'categoria', + 'compOrarioArrivoZeroEffettivo', + 'compOrarioPartenzaZeroEffettivo', + 'destinazione', + 'numeroTreno', + 'orarioArrivo', + 'orarioPartenza', + 'origine', + 'subTitle', + ] + +DEFAULT_NAME = "Train {}" + +CONF_NAME = 'train_name' +CONF_STATION_ID = 'station_id' +CONF_STATION_NAME = 'station_name' +CONF_TRAIN_ID = 'train_id' + +ARRIVED_STRING = 'Arrived' +CANCELLED_STRING = 'Cancelled' +NOT_DEPARTED_STRING = "Not departed yet" +NO_INFORMATION_STRING = "No information for this train now" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TRAIN_ID): cv.string, + vol.Required(CONF_STATION_ID): cv.string, + vol.Optional(CONF_NAME): cv.string, + }) + + +@asyncio.coroutine +def async_setup_platform(hass, config, + async_add_devices, discovery_info=None): + """Setup the ViaggiaTreno platform.""" + train_id = config.get(CONF_TRAIN_ID) + station_id = config.get(CONF_STATION_ID) + name = config.get(CONF_NAME) + if not name: + name = DEFAULT_NAME.format(train_id) + async_add_devices([ViaggiaTrenoSensor(train_id, station_id, name)]) + + +@asyncio.coroutine +def async_http_request(hass, uri): + """Perform actual request.""" + try: + session = hass.helpers.aiohttp_client.async_get_clientsession(hass) + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + req = yield from session.get(uri) + if req.status != 200: + return {'error': req.status} + else: + json_response = yield from req.json() + return json_response + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + _LOGGER.error("Cannot connect to ViaggiaTreno API endpoint: %s", exc) + except ValueError: + _LOGGER.error("Received non-JSON data from ViaggiaTreno API endpoint") + + +class ViaggiaTrenoSensor(Entity): + """Implementation of a ViaggiaTreno sensor.""" + + def __init__(self, train_id, station_id, name): + """Initialize the sensor.""" + self._state = None + self._attributes = {} + self._unit = '' + self._icon = ICON + self._station_id = station_id + self._name = name + + self.uri = VIAGGIATRENO_ENDPOINT.format( + station_id=station_id, + train_id=train_id) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def device_state_attributes(self): + """Return extra attributes.""" + self._attributes[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + return self._attributes + + @staticmethod + def has_departed(data): + """Check if the train has actually departed.""" + try: + first_station = data['fermate'][0] + if data['oraUltimoRilevamento'] or first_station['effettiva']: + return True + except ValueError: + _LOGGER.error("Cannot fetch first station: %s", data) + return False + + @staticmethod + def has_arrived(data): + """Check if the train has already arrived.""" + last_station = data['fermate'][-1] + if not last_station['effettiva']: + return False + return True + + @staticmethod + def is_cancelled(data): + """Check if the train is cancelled.""" + if data['tipoTreno'] == 'ST' and data['provvedimento'] == 1: + return True + return False + + @asyncio.coroutine + def async_update(self): + """Update state.""" + uri = self.uri + res = yield from async_http_request(self.hass, uri) + if res.get('error', ''): + if res['error'] == 204: + self._state = NO_INFORMATION_STRING + self._unit = '' + else: + self._state = "Error: {}".format(res['error']) + self._unit = '' + else: + for i in MONITORED_INFO: + self._attributes[i] = res[i] + + if self.is_cancelled(res): + self._state = CANCELLED_STRING + self._icon = 'mdi:cancel' + self._unit = '' + elif not self.has_departed(res): + self._state = NOT_DEPARTED_STRING + self._unit = '' + elif self.has_arrived(res): + self._state = ARRIVED_STRING + self._unit = '' + else: + self._state = res.get('ritardo') + self._unit = 'min' + self._icon = ICON From 270846c2f5121562561a32fca1ac9a8b9bd3686a Mon Sep 17 00:00:00 2001 From: ziotibia81 Date: Thu, 16 Nov 2017 07:17:10 +0100 Subject: [PATCH 112/137] Modbus switch register support (#10563) * Update modbus.py * Fix blank linea and whitespaces * Fix visual indent * Fix visual indent * fix multiple statements on one line * Typo * Disable pylint check # pylint: disable=super-init-not-called * Fix code style --- homeassistant/components/switch/modbus.py | 152 ++++++++++++++++++++-- 1 file changed, 138 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index e6342617f28..c731b336dfb 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -8,7 +8,8 @@ import logging import voluptuous as vol import homeassistant.components.modbus as modbus -from homeassistant.const import CONF_NAME, CONF_SLAVE +from homeassistant.const import ( + CONF_NAME, CONF_SLAVE, CONF_COMMAND_ON, CONF_COMMAND_OFF) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers import config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -18,32 +19,76 @@ DEPENDENCIES = ['modbus'] CONF_COIL = "coil" CONF_COILS = "coils" +CONF_REGISTER = "register" +CONF_REGISTERS = "registers" +CONF_VERIFY_STATE = "verify_state" +CONF_VERIFY_REGISTER = "verify_register" +CONF_REGISTER_TYPE = "register_type" +CONF_STATE_ON = "state_on" +CONF_STATE_OFF = "state_off" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COILS): [{ - vol.Required(CONF_COIL): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_SLAVE): cv.positive_int, - }] +REGISTER_TYPE_HOLDING = 'holding' +REGISTER_TYPE_INPUT = 'input' + +REGISTERS_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_SLAVE): cv.positive_int, + vol.Required(CONF_REGISTER): cv.positive_int, + vol.Required(CONF_COMMAND_ON): cv.positive_int, + vol.Required(CONF_COMMAND_OFF): cv.positive_int, + vol.Optional(CONF_VERIFY_STATE, default=True): cv.boolean, + vol.Optional(CONF_VERIFY_REGISTER, default=None): + cv.positive_int, + vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): + vol.In([REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT]), + vol.Optional(CONF_STATE_ON, default=None): cv.positive_int, + vol.Optional(CONF_STATE_OFF, default=None): cv.positive_int, }) +COILS_SCHEMA = vol.Schema({ + vol.Required(CONF_COIL): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SLAVE): cv.positive_int, +}) + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_COILS, CONF_REGISTERS), + PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_COILS): [COILS_SCHEMA], + vol.Optional(CONF_REGISTERS): [REGISTERS_SCHEMA] + })) + def setup_platform(hass, config, add_devices, discovery_info=None): """Read configuration and create Modbus devices.""" switches = [] - for coil in config.get("coils"): - switches.append(ModbusCoilSwitch( - coil.get(CONF_NAME), - coil.get(CONF_SLAVE), - coil.get(CONF_COIL))) + if CONF_COILS in config: + for coil in config.get(CONF_COILS): + switches.append(ModbusCoilSwitch( + coil.get(CONF_NAME), + coil.get(CONF_SLAVE), + coil.get(CONF_COIL))) + if CONF_REGISTERS in config: + for register in config.get(CONF_REGISTERS): + switches.append(ModbusRegisterSwitch( + register.get(CONF_NAME), + register.get(CONF_SLAVE), + register.get(CONF_REGISTER), + register.get(CONF_COMMAND_ON), + register.get(CONF_COMMAND_OFF), + register.get(CONF_VERIFY_STATE), + register.get(CONF_VERIFY_REGISTER), + register.get(CONF_REGISTER_TYPE), + register.get(CONF_STATE_ON), + register.get(CONF_STATE_OFF))) add_devices(switches) class ModbusCoilSwitch(ToggleEntity): - """Representation of a Modbus switch.""" + """Representation of a Modbus coil switch.""" def __init__(self, name, slave, coil): - """Initialize the switch.""" + """Initialize the coil switch.""" self._name = name self._slave = int(slave) if slave else None self._coil = int(coil) @@ -77,3 +122,82 @@ class ModbusCoilSwitch(ToggleEntity): 'No response from modbus slave %s coil %s', self._slave, self._coil) + + +class ModbusRegisterSwitch(ModbusCoilSwitch): + """Representation of a Modbus register switch.""" + + # pylint: disable=super-init-not-called + def __init__(self, name, slave, register, command_on, + command_off, verify_state, verify_register, + register_type, state_on, state_off): + """Initialize the register switch.""" + self._name = name + self._slave = slave + self._register = register + self._command_on = command_on + self._command_off = command_off + self._verify_state = verify_state + self._verify_register = ( + verify_register if verify_register else self._register) + self._register_type = register_type + self._state_on = ( + state_on if state_on else self._command_on) + self._state_off = ( + state_off if state_off else self._command_off) + self._is_on = None + + def turn_on(self, **kwargs): + """Set switch on.""" + modbus.HUB.write_register( + self._slave, + self._register, + self._command_on) + if not self._verify_state: + self._is_on = True + + def turn_off(self, **kwargs): + """Set switch off.""" + modbus.HUB.write_register( + self._slave, + self._register, + self._command_off) + if not self._verify_state: + self._is_on = False + + def update(self): + """Update the state of the switch.""" + if not self._verify_state: + return + + value = 0 + if self._register_type == REGISTER_TYPE_INPUT: + result = modbus.HUB.read_input_registers( + self._slave, + self._register, + 1) + else: + result = modbus.HUB.read_holding_registers( + self._slave, + self._register, + 1) + + try: + value = int(result.registers[0]) + except AttributeError: + _LOGGER.error( + 'No response from modbus slave %s register %s', + self._slave, + self._verify_register) + + if value == self._state_on: + self._is_on = True + elif value == self._state_off: + self._is_on = False + else: + _LOGGER.error( + 'Unexpected response from modbus slave %s ' + 'register %s, got 0x%2x', + self._slave, + self._verify_register, + value) From e20fd3b973e2e593649e0e4cfec175531be9fb7d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 16 Nov 2017 07:35:18 +0100 Subject: [PATCH 113/137] Upgrade mypy to 0.550 (#10591) --- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1aa909bc9bb..3edfa168f79 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.540 +mypy==0.550 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea2700f7c9b..18ea3192a98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.540 +mypy==0.550 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 From f494c32866c7e6601995c915399ac4a152b7de58 Mon Sep 17 00:00:00 2001 From: boltgolt Date: Thu, 16 Nov 2017 07:41:39 +0100 Subject: [PATCH 114/137] Small fix to be able to use mac and vendor in "device_tracker_new_device" event. (#10537) * Small fix to be able to use mac and vendor in EVENT_NEW_DEVICE event * Missed device_tracker test --- homeassistant/components/device_tracker/__init__.py | 13 ++++++++----- tests/components/device_tracker/test_init.py | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 05131a039cd..0b18cc72f6e 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -76,6 +76,7 @@ ATTR_LOCATION_NAME = 'location_name' ATTR_MAC = 'mac' ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' +ATTR_VENDOR = 'vendor' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' @@ -285,11 +286,6 @@ class DeviceTracker(object): if device.track: yield from device.async_update_ha_state() - self.hass.bus.async_fire(EVENT_NEW_DEVICE, { - ATTR_ENTITY_ID: device.entity_id, - ATTR_HOST_NAME: device.host_name, - }) - # During init, we ignore the group if self.group and self.track_new: self.group.async_set_group( @@ -299,6 +295,13 @@ class DeviceTracker(object): # lookup mac vendor string to be stored in config yield from device.set_vendor_for_mac() + self.hass.bus.async_fire(EVENT_NEW_DEVICE, { + ATTR_ENTITY_ID: device.entity_id, + ATTR_HOST_NAME: device.host_name, + ATTR_MAC: device.mac, + ATTR_VENDOR: device.vendor, + }) + # update known_devices.yaml self.hass.async_add_job( self.async_update_config( diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index a8531e2aa69..704b2590f12 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -481,6 +481,8 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert test_events[0].data == { 'entity_id': 'device_tracker.hello', 'host_name': 'hello', + 'mac': 'MAC_1', + 'vendor': 'unknown', } # pylint: disable=invalid-name From d4bd4c114b0430f50281df06ef46d007bb773a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Thu, 16 Nov 2017 08:00:43 +0100 Subject: [PATCH 115/137] add support for color temperature and color to Google Assistant (#10039) * add support for color temperature and color; also add some extra deviceInfo attributes * change so that default behaviour doesn't turn off device if the action isn't handled * add tests * fix lint * more lint * use attributes were applicable * removed debug logging * fix unassigned if only None returned * report more data in QUERY * better tests for color and temperature * fixes after dev merge * remove deviceInfo as not part of a device state (PR #10399) * fix after merge --- .../components/google_assistant/http.py | 1 + .../components/google_assistant/smart_home.py | 73 +++++++++++++++++-- .../google_assistant/test_google_assistant.py | 36 ++++++++- .../google_assistant/test_smart_home.py | 51 +++++++++++++ 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 71a4ff9ce3a..ab9705432fb 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -128,6 +128,7 @@ class GoogleAssistantView(HomeAssistantView): ent_ids = [ent.get('id') for ent in command.get('devices', [])] execution = command.get('execution')[0] for eid in ent_ids: + success = False domain = eid.split('.')[0] (service, service_data) = determine_service( eid, execution.get('command'), execution.get('params'), diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 42cb555fe3c..cd1583fb377 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -8,6 +8,7 @@ from aiohttp.web import Request, Response # NOQA from typing import Dict, Tuple, Any, Optional # NOQA from homeassistant.helpers.entity import Entity # NOQA from homeassistant.core import HomeAssistant # NOQA +from homeassistant.util import color from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.const import ( @@ -22,7 +23,8 @@ from homeassistant.components import ( from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( - ATTR_GOOGLE_ASSISTANT_NAME, ATTR_GOOGLE_ASSISTANT_TYPE, + ATTR_GOOGLE_ASSISTANT_NAME, COMMAND_COLOR, + ATTR_GOOGLE_ASSISTANT_TYPE, COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE, COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, COMMAND_THERMOSTAT_SET_MODE, @@ -78,6 +80,7 @@ def entity_to_device(entity: Entity, units: UnitSystem): device = { 'id': entity.entity_id, 'name': {}, + 'attributes': {}, 'traits': [], 'willReportState': False, } @@ -102,6 +105,23 @@ def entity_to_device(entity: Entity, units: UnitSystem): for feature, trait in class_data[2].items(): if feature & supported > 0: device['traits'].append(trait) + + # Actions require this attributes for a device + # supporting temperature + # For IKEA trådfri, these attributes only seem to + # be set only if the device is on? + if trait == TRAIT_COLOR_TEMP: + if entity.attributes.get( + light.ATTR_MAX_MIREDS) is not None: + device['attributes']['temperatureMinK'] = \ + int(round(color.color_temperature_mired_to_kelvin( + entity.attributes.get(light.ATTR_MAX_MIREDS)))) + if entity.attributes.get( + light.ATTR_MIN_MIREDS) is not None: + device['attributes']['temperatureMaxK'] = \ + int(round(color.color_temperature_mired_to_kelvin( + entity.attributes.get(light.ATTR_MIN_MIREDS)))) + if entity.domain == climate.DOMAIN: modes = ','.join( m for m in entity.attributes.get(climate.ATTR_OPERATION_LIST, []) @@ -156,12 +176,35 @@ def query_device(entity: Entity, units: UnitSystem) -> dict: final_brightness = 100 * (final_brightness / 255) - return { + query_response = { "on": final_state, "online": True, "brightness": int(final_brightness) } + supported_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported_features & \ + (light.SUPPORT_COLOR_TEMP | light.SUPPORT_RGB_COLOR): + query_response["color"] = {} + + if entity.attributes.get(light.ATTR_COLOR_TEMP) is not None: + query_response["color"]["temperature"] = \ + int(round(color.color_temperature_mired_to_kelvin( + entity.attributes.get(light.ATTR_COLOR_TEMP)))) + + if entity.attributes.get(light.ATTR_COLOR_NAME) is not None: + query_response["color"]["name"] = \ + entity.attributes.get(light.ATTR_COLOR_NAME) + + if entity.attributes.get(light.ATTR_RGB_COLOR) is not None: + color_rgb = entity.attributes.get(light.ATTR_RGB_COLOR) + if color_rgb is not None: + query_response["color"]["spectrumRGB"] = \ + int(color.color_rgb_to_hex( + color_rgb[0], color_rgb[1], color_rgb[2]), 16) + + return query_response + # erroneous bug on old pythons and pylint # https://github.com/PyCQA/pylint/issues/1212 @@ -217,7 +260,27 @@ def determine_service( service_data['brightness'] = int(brightness / 100 * 255) return (SERVICE_TURN_ON, service_data) - if command == COMMAND_ACTIVATESCENE or (COMMAND_ONOFF == command and - params.get('on') is True): + _LOGGER.debug("Handling command %s with data %s", command, params) + if command == COMMAND_COLOR: + color_data = params.get('color') + if color_data is not None: + if color_data.get('temperature', 0) > 0: + service_data[light.ATTR_KELVIN] = color_data.get('temperature') + return (SERVICE_TURN_ON, service_data) + if color_data.get('spectrumRGB', 0) > 0: + # blue is 255 so pad up to 6 chars + hex_value = \ + ('%0x' % int(color_data.get('spectrumRGB'))).zfill(6) + service_data[light.ATTR_RGB_COLOR] = \ + color.rgb_hex_to_rgb_list(hex_value) + return (SERVICE_TURN_ON, service_data) + + if command == COMMAND_ACTIVATESCENE: return (SERVICE_TURN_ON, service_data) - return (SERVICE_TURN_OFF, service_data) + + if COMMAND_ONOFF == command: + if params.get('on') is True: + return (SERVICE_TURN_ON, service_data) + return (SERVICE_TURN_OFF, service_data) + + return (None, service_data) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index c21c63b0d52..dba10608991 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -181,6 +181,8 @@ def test_query_request(hass_fixture, assistant_client): 'id': "light.ceiling_lights", }, { 'id': "light.bed_light", + }, { + 'id': "light.kitchen_lights", }] } }] @@ -193,10 +195,12 @@ def test_query_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - assert len(devices) == 2 + assert len(devices) == 3 assert devices['light.bed_light']['on'] is False assert devices['light.ceiling_lights']['on'] is True assert devices['light.ceiling_lights']['brightness'] == 70 + assert devices['light.kitchen_lights']['color']['spectrumRGB'] == 16727919 + assert devices['light.kitchen_lights']['color']['temperature'] == 4166 @asyncio.coroutine @@ -321,6 +325,31 @@ def test_execute_request(hass_fixture, assistant_client): "on": False } }] + }, { + "devices": [{ + "id": "light.kitchen_lights", + }], + "execution": [{ + "command": "action.devices.commands.ColorAbsolute", + "params": { + "color": { + "spectrumRGB": 16711680, + "temperature": 2100 + } + } + }] + }, { + "devices": [{ + "id": "light.kitchen_lights", + }], + "execution": [{ + "command": "action.devices.commands.ColorAbsolute", + "params": { + "color": { + "spectrumRGB": 16711680 + } + } + }] }] } }] @@ -333,7 +362,10 @@ def test_execute_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid commands = body['payload']['commands'] - assert len(commands) == 3 + assert len(commands) == 5 ceiling = hass_fixture.states.get('light.ceiling_lights') assert ceiling.state == 'off' + kitchen = hass_fixture.states.get('light.kitchen_lights') + assert kitchen.attributes.get(light.ATTR_COLOR_TEMP) == 476 + assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0) assert hass_fixture.states.get('switch.decorative_lights').state == 'off' diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 6712b390dbb..2668c0cecfc 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -17,6 +17,57 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness const.SERVICE_TURN_ON, {'entity_id': 'light.test', 'brightness': 242} ) +}, { # Test light color temperature + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_COLOR, + 'params': { + 'color': { + 'temperature': 2300, + 'name': 'warm white' + } + }, + 'expected': ( + const.SERVICE_TURN_ON, + {'entity_id': 'light.test', 'kelvin': 2300} + ) +}, { # Test light color blue + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_COLOR, + 'params': { + 'color': { + 'spectrumRGB': 255, + 'name': 'blue' + } + }, + 'expected': ( + const.SERVICE_TURN_ON, + {'entity_id': 'light.test', 'rgb_color': [0, 0, 255]} + ) +}, { # Test light color yellow + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_COLOR, + 'params': { + 'color': { + 'spectrumRGB': 16776960, + 'name': 'yellow' + } + }, + 'expected': ( + const.SERVICE_TURN_ON, + {'entity_id': 'light.test', 'rgb_color': [255, 255, 0]} + ) +}, { # Test unhandled action/service + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_COLOR, + 'params': { + 'color': { + 'unhandled': 2300 + } + }, + 'expected': ( + None, + {'entity_id': 'light.test'} + ) }, { # Test switch to light custom type 'entity_id': 'switch.decorative_lights', 'command': ga.const.COMMAND_ONOFF, From 1719fa70080521b3364550822edddb1e585c8380 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 16 Nov 2017 08:03:41 +0100 Subject: [PATCH 116/137] Cleanup old stale restore feature (#10593) * Cleanup old stale restore feature * cleanup * Update __init__.py * Update test_demo.py * Lint --- homeassistant/components/light/__init__.py | 15 -------- homeassistant/components/light/demo.py | 24 ------------- tests/components/light/test_demo.py | 40 ++-------------------- 3 files changed, 2 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d69d6991ff0..e4fb4542205 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -23,7 +23,6 @@ from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import async_restore_state import homeassistant.util.color as color_util DOMAIN = "light" @@ -140,14 +139,6 @@ PROFILE_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) -def extract_info(state): - """Extract light parameters from a state object.""" - params = {key: state.attributes[key] for key in PROP_TO_ATTR - if key in state.attributes} - params['is_on'] = state.state == STATE_ON - return params - - @bind_hass def is_on(hass, entity_id=None): """Return if the lights are on based on the statemachine.""" @@ -431,9 +422,3 @@ class Light(ToggleEntity): def supported_features(self): """Flag supported features.""" return 0 - - @asyncio.coroutine - def async_added_to_hass(self): - """Component added, restore_state using platforms.""" - if hasattr(self, 'async_restore_state'): - yield from async_restore_state(self, extract_info) diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index 22ab404a3b2..d01611716eb 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -4,7 +4,6 @@ Demo light platform that implements lights. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -import asyncio import random from homeassistant.components.light import ( @@ -150,26 +149,3 @@ class DemoLight(Light): # As we have disabled polling, we need to inform # Home Assistant about updates in our state ourselves. self.schedule_update_ha_state() - - @asyncio.coroutine - def async_restore_state(self, is_on, **kwargs): - """Restore the demo state.""" - self._state = is_on - - if 'brightness' in kwargs: - self._brightness = kwargs['brightness'] - - if 'color_temp' in kwargs: - self._ct = kwargs['color_temp'] - - if 'rgb_color' in kwargs: - self._rgb = kwargs['rgb_color'] - - if 'xy_color' in kwargs: - self._xy_color = kwargs['xy_color'] - - if 'white_value' in kwargs: - self._white = kwargs['white_value'] - - if 'effect' in kwargs: - self._effect = kwargs['effect'] diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index b4576b174d6..8a7d648e6f2 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -1,14 +1,11 @@ """The tests for the demo light component.""" # pylint: disable=protected-access -import asyncio import unittest -from homeassistant.core import State, CoreState -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import setup_component import homeassistant.components.light as light -from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE -from tests.common import get_test_home_assistant, mock_component +from tests.common import get_test_home_assistant ENTITY_LIGHT = 'light.bed_light' @@ -79,36 +76,3 @@ class TestDemoLight(unittest.TestCase): light.turn_off(self.hass) self.hass.block_till_done() self.assertFalse(light.is_on(self.hass, ENTITY_LIGHT)) - - -@asyncio.coroutine -def test_restore_state(hass): - """Test state gets restored.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting - hass.data[DATA_RESTORE_CACHE] = { - 'light.bed_light': State('light.bed_light', 'on', { - 'brightness': 'value-brightness', - 'color_temp': 'value-color_temp', - 'rgb_color': 'value-rgb_color', - 'xy_color': 'value-xy_color', - 'white_value': 'value-white_value', - 'effect': 'value-effect', - }), - } - - yield from async_setup_component(hass, 'light', { - 'light': { - 'platform': 'demo', - }}) - - state = hass.states.get('light.bed_light') - assert state is not None - assert state.entity_id == 'light.bed_light' - assert state.state == 'on' - assert state.attributes.get('brightness') == 'value-brightness' - assert state.attributes.get('color_temp') == 'value-color_temp' - assert state.attributes.get('rgb_color') == 'value-rgb_color' - assert state.attributes.get('xy_color') == 'value-xy_color' - assert state.attributes.get('white_value') == 'value-white_value' - assert state.attributes.get('effect') == 'value-effect' From 3dbae5ca5b22b2c0ed22cd1a46e64eb9486cc810 Mon Sep 17 00:00:00 2001 From: Colin Dunn Date: Thu, 16 Nov 2017 18:16:22 +1100 Subject: [PATCH 117/137] Correct input_datetime initial value parsing (#10417) * Correct input_datetime initial value parsing * Correct input_datetime initial value parsing --- homeassistant/components/input_datetime.py | 10 +++++----- tests/components/test_input_datetime.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/input_datetime.py b/homeassistant/components/input_datetime.py index 9dd09f2c245..fecc31f14ae 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -46,7 +46,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_HAS_DATE): cv.boolean, vol.Required(CONF_HAS_TIME): cv.boolean, vol.Optional(CONF_ICON): cv.icon, - vol.Optional(CONF_INITIAL): cv.datetime, + vol.Optional(CONF_INITIAL): cv.string, }, cv.has_at_least_one_key_value((CONF_HAS_DATE, True), (CONF_HAS_TIME, True)))}) }, extra=vol.ALLOW_EXTRA) @@ -137,15 +137,15 @@ class InputDatetime(Entity): old_state = yield from async_get_last_state(self.hass, self.entity_id) if old_state is not None: - restore_val = dt_util.parse_datetime(old_state.state) + restore_val = old_state.state if restore_val is not None: if not self._has_date: - self._current_datetime = restore_val.time() + self._current_datetime = dt_util.parse_time(restore_val) elif not self._has_time: - self._current_datetime = restore_val.date() + self._current_datetime = dt_util.parse_date(restore_val) else: - self._current_datetime = restore_val + self._current_datetime = dt_util.parse_datetime(restore_val) def has_date(self): """Return whether the input datetime carries a date.""" diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py index af664f36a53..5d3f1782831 100644 --- a/tests/components/test_input_datetime.py +++ b/tests/components/test_input_datetime.py @@ -102,7 +102,7 @@ def test_set_datetime_time(hass): @asyncio.coroutine def test_set_invalid(hass): """Test set_datetime method with only time.""" - initial = datetime.datetime(2017, 1, 1, 0, 0) + initial = '2017-01-01' yield from async_setup_component(hass, DOMAIN, { DOMAIN: { 'test_date': { @@ -124,7 +124,7 @@ def test_set_invalid(hass): yield from hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == str(initial.date()) + assert state.state == initial @asyncio.coroutine @@ -159,8 +159,8 @@ def test_set_datetime_date(hass): def test_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache(hass, ( - State('input_datetime.test_time', '2017-09-07 19:46:00'), - State('input_datetime.test_date', '2017-09-07 19:46:00'), + State('input_datetime.test_time', '19:46:00'), + State('input_datetime.test_date', '2017-09-07'), State('input_datetime.test_datetime', '2017-09-07 19:46:00'), State('input_datetime.test_bogus_data', 'this is not a date'), )) From 79ca93f892fd3ca2370d9c26ce50b6c92213a245 Mon Sep 17 00:00:00 2001 From: Milan V Date: Thu, 16 Nov 2017 13:11:46 +0100 Subject: [PATCH 118/137] Change generic thermostat to control heating on mode change Off -> Auto (#10601) * Change generic thermostat to control heating on mode change Off -> Auto * Fix typo --- .../components/climate/generic_thermostat.py | 1 + .../climate/test_generic_thermostat.py | 44 +++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 191960d2848..0c0c837b850 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -163,6 +163,7 @@ class GenericThermostat(ClimateDevice): """Set operation mode.""" if operation_mode == STATE_AUTO: self._enabled = True + self._async_control_heating() elif operation_mode == STATE_OFF: self._enabled = False if self._is_device_active: diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 74b2186b8d7..bb42ef177f0 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -1,9 +1,9 @@ """The tests for the generic_thermostat.""" import asyncio import datetime -import pytz import unittest from unittest import mock +import pytz import homeassistant.core as ha from homeassistant.core import callback @@ -54,13 +54,16 @@ class TestSetupClimateGenericThermostat(unittest.TestCase): 'climate': config}) def test_valid_conf(self): - """Test set up genreic_thermostat with valid config values.""" - self.assertTrue(setup_component(self.hass, 'climate', - {'climate': { - 'platform': 'generic_thermostat', - 'name': 'test', - 'heater': ENT_SWITCH, - 'target_sensor': ENT_SENSOR}})) + """Test set up generic_thermostat with valid config values.""" + self.assertTrue( + setup_component(self.hass, 'climate', + {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR + }}) + ) def test_setup_with_sensor(self): """Test set up heat_control with sensor to trigger update at init.""" @@ -243,6 +246,31 @@ class TestClimateGenericThermostat(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(self.calls)) + @mock.patch('logging.Logger.error') + def test_invalid_operating_mode(self, log_mock): + """Test error handling for invalid operation mode.""" + climate.set_operation_mode(self.hass, 'invalid mode') + self.hass.block_till_done() + self.assertEqual(log_mock.call_count, 1) + + def test_operating_mode_auto(self): + """Test change mode from OFF to AUTO. + + Switch turns on when temp below setpoint and mode changes. + """ + climate.set_operation_mode(self.hass, STATE_OFF) + climate.set_temperature(self.hass, 30) + self._setup_sensor(25) + self.hass.block_till_done() + self._setup_switch(False) + climate.set_operation_mode(self.hass, climate.STATE_AUTO) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): """Setup the test sensor.""" self.hass.states.set(ENT_SENSOR, temp, { From eb7643e163a04e5479c1592603bae601613c37a9 Mon Sep 17 00:00:00 2001 From: Milan V Date: Thu, 16 Nov 2017 16:26:23 +0100 Subject: [PATCH 119/137] Improve WUnderground config validation (#10573) * Fix WUnderground config validation * Fix indentation --- homeassistant/components/sensor/wunderground.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 2fcb13e13dd..c0763c4fefa 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -616,14 +616,13 @@ LANG_CODES = [ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_PWS_ID): cv.string, - vol.Optional(CONF_LANG, default=DEFAULT_LANG): - vol.All(vol.In(LANG_CODES)), + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.All(vol.In(LANG_CODES)), vol.Inclusive(CONF_LATITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.latitude, vol.Inclusive(CONF_LONGITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.longitude, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]), }) From bd5a16d70b3eb432af5bc46cdbb9c9d8bb07ea14 Mon Sep 17 00:00:00 2001 From: Mitko Masarliev Date: Thu, 16 Nov 2017 17:47:37 +0200 Subject: [PATCH 120/137] update hbmqtt to 0.9.1 (#10611) --- homeassistant/components/mqtt/server.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py index 0e866723b34..db251ab4180 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['hbmqtt==0.8'] +REQUIREMENTS = ['hbmqtt==0.9.1'] DEPENDENCIES = ['http'] # None allows custom config to be created through generate_config diff --git a/requirements_all.txt b/requirements_all.txt index 29efb610897..d22090708fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -316,7 +316,7 @@ ha-philipsjs==0.0.1 haversine==0.4.5 # homeassistant.components.mqtt.server -hbmqtt==0.8 +hbmqtt==0.9.1 # homeassistant.components.climate.heatmiser heatmiserV3==0.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18ea3192a98..201da6be2b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -68,7 +68,7 @@ ha-ffmpeg==1.9 haversine==0.4.5 # homeassistant.components.mqtt.server -hbmqtt==0.8 +hbmqtt==0.9.1 # homeassistant.components.binary_sensor.workday holidays==0.8.1 From 072ed7ea13c37d1bfb180acbe943c878590bacdb Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 16 Nov 2017 19:10:25 +0200 Subject: [PATCH 121/137] Allow to pass YandexTTS options via sevice call (#10578) --- homeassistant/components/tts/__init__.py | 5 +-- homeassistant/components/tts/yandextts.py | 21 ++++++++++--- tests/components/tts/test_yandextts.py | 37 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 9f36b2fb78f..59090b98e94 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -286,10 +286,11 @@ class SpeechManager(object): options = options or provider.default_options if options is not None: invalid_opts = [opt_name for opt_name in options.keys() - if opt_name not in provider.supported_options] + if opt_name not in (provider.supported_options or + [])] if invalid_opts: raise HomeAssistantError( - "Invalid options found: %s", invalid_opts) + "Invalid options found: {}".format(invalid_opts)) options_key = ctypes.c_size_t(hash(frozenset(options))).value else: options_key = '-' diff --git a/homeassistant/components/tts/yandextts.py b/homeassistant/components/tts/yandextts.py index 05daad55412..b5e965a5b50 100644 --- a/homeassistant/components/tts/yandextts.py +++ b/homeassistant/components/tts/yandextts.py @@ -63,6 +63,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Range(min=MIN_SPEED, max=MAX_SPEED) }) +SUPPORTED_OPTIONS = [ + CONF_CODEC, + CONF_VOICE, + CONF_EMOTION, + CONF_SPEED, +] + @asyncio.coroutine def async_get_engine(hass, config): @@ -94,11 +101,17 @@ class YandexSpeechKitProvider(Provider): """Return list of supported languages.""" return SUPPORT_LANGUAGES + @property + def supported_options(self): + """Return list of supported options.""" + return SUPPORTED_OPTIONS + @asyncio.coroutine def async_get_tts_audio(self, message, language, options=None): """Load TTS from yandex.""" websession = async_get_clientsession(self.hass) actual_language = language + options = options or {} try: with async_timeout.timeout(10, loop=self.hass.loop): @@ -106,10 +119,10 @@ class YandexSpeechKitProvider(Provider): 'text': message, 'lang': actual_language, 'key': self._key, - 'speaker': self._speaker, - 'format': self._codec, - 'emotion': self._emotion, - 'speed': self._speed + 'speaker': options.get(CONF_VOICE, self._speaker), + 'format': options.get(CONF_CODEC, self._codec), + 'emotion': options.get(CONF_EMOTION, self._emotion), + 'speed': options.get(CONF_SPEED, self._speed) } request = yield from websession.get( diff --git a/tests/components/tts/test_yandextts.py b/tests/components/tts/test_yandextts.py index 1ed92f34ebe..e08229631cf 100644 --- a/tests/components/tts/test_yandextts.py +++ b/tests/components/tts/test_yandextts.py @@ -363,3 +363,40 @@ class TestTTSYandexPlatform(object): assert len(aioclient_mock.mock_calls) == 1 assert len(calls) == 1 + + def test_service_say_specified_options(self, aioclient_mock): + """Test service call say with options.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'evil', + 'speed': 2 + } + aioclient_mock.get( + self._base_url, status=200, content=b'test', params=url_param) + config = { + tts.DOMAIN: { + 'platform': 'yandextts', + 'api_key': '1234567xx', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'yandextts_say', { + tts.ATTR_MESSAGE: "HomeAssistant", + 'options': { + 'emotion': 'evil', + 'speed': 2, + } + }) + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 From 693d32fa6802ea59bac0a1fa8c10ef583b65ce1d Mon Sep 17 00:00:00 2001 From: Jan Losinski Date: Fri, 17 Nov 2017 02:32:26 +0100 Subject: [PATCH 122/137] Snapcast: bump version and enable reconnect. (#10626) This bumps the used snapcast version to 2.0.8 and enables the new reconnect feature that causes the component to reconnect to a server if the connection was lost. This fixes the ned to restart Home Assstant after a snapcast reboot, as described in issue #10264. Signed-off-by: Jan Losinski --- homeassistant/components/media_player/snapcast.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 3f1607831e5..54015bec277 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -20,7 +20,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['snapcast==2.0.7'] +REQUIREMENTS = ['snapcast==2.0.8'] _LOGGER = logging.getLogger(__name__) @@ -80,7 +80,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): try: server = yield from snapcast.control.create_server( - hass.loop, host, port) + hass.loop, host, port, reconnect=True) except socket.gaierror: _LOGGER.error('Could not connect to Snapcast server at %s:%d', host, port) diff --git a/requirements_all.txt b/requirements_all.txt index d22090708fe..2c1112ae49e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1022,7 +1022,7 @@ sleepyq==0.6 # smbus-cffi==0.5.1 # homeassistant.components.media_player.snapcast -snapcast==2.0.7 +snapcast==2.0.8 # homeassistant.components.climate.honeywell somecomfort==0.4.1 From aa6b37912a1b3c68b20a83558ad83d46f0a57626 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 17 Nov 2017 00:03:05 -0500 Subject: [PATCH 123/137] Fix async missing decorators (#10628) --- homeassistant/helpers/entity.py | 5 ++++- homeassistant/helpers/entity_component.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 8e032bc48a1..4a967e50995 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -11,7 +11,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, DEVICE_DEFAULT_NAME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, ATTR_DEVICE_CLASS) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.config import DATA_CUSTOMIZE from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.util import ensure_unique_string, slugify @@ -41,6 +41,7 @@ def generate_entity_id(entity_id_format: str, name: Optional[str], entity_id_format.format(slugify(name)), current_ids) +@callback def async_generate_entity_id(entity_id_format: str, name: Optional[str], current_ids: Optional[List[str]]=None, hass: Optional[HomeAssistant]=None) -> str: @@ -271,10 +272,12 @@ class Entity(object): """ self.hass.add_job(self.async_update_ha_state(force_refresh)) + @callback 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)) + @asyncio.coroutine def async_device_update(self, warning=True): """Process 'update' or 'async_update' from entity. diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index e805f277483..9b25b8ddbd4 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -97,6 +97,7 @@ class EntityComponent(object): expand_group ).result() + @callback def async_extract_from_service(self, service, expand_group=True): """Extract all known and available entities from a service call. From 6cf2e758a822fe54537efe49cdc4c5fd4dc0af9f Mon Sep 17 00:00:00 2001 From: Corey Pauley Date: Thu, 16 Nov 2017 23:09:00 -0600 Subject: [PATCH 124/137] Alexa slot synonym fix (#10614) * Added logic to the alexa component for handling slot synonyms * Moved note with long url to the top of the file * Just made a tiny url instead of messing with Flake8 * Refactored to be more Pythonic * Put trailing comma back --- homeassistant/components/alexa/const.py | 2 + homeassistant/components/alexa/intent.py | 63 +++++++++++----- tests/components/alexa/test_intent.py | 95 +++++++++++++++++++++++- 3 files changed, 139 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 9550b6dbade..c243fc12d5e 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -15,4 +15,6 @@ ATTR_STREAM_URL = 'streamUrl' ATTR_MAIN_TEXT = 'mainText' ATTR_REDIRECTION_URL = 'redirectionURL' +SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH' + DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index c3a0155e312..3ade199aabb 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -3,6 +3,7 @@ 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 enum @@ -13,7 +14,7 @@ from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import intent from homeassistant.components import http -from .const import DOMAIN +from .const import DOMAIN, SYN_RESOLUTION_MATCH INTENTS_API_ENDPOINT = '/api/alexa' @@ -123,6 +124,43 @@ class AlexaIntentsView(http.HomeAssistantView): return self.json(alexa_response) +def resolve_slot_synonyms(key, request): + """Check slot request for synonym resolutions.""" + # Default to the spoken slot value if more than one or none are found. For + # reference to the request object structure, see the Alexa docs: + # https://tinyurl.com/ybvm7jhs + resolved_value = request['value'] + + if ('resolutions' in request and + 'resolutionsPerAuthority' in request['resolutions'] and + len(request['resolutions']['resolutionsPerAuthority']) >= 1): + + # Extract all of the possible values from each authority with a + # successful match + possible_values = [] + + for entry in request['resolutions']['resolutionsPerAuthority']: + if entry['status']['code'] != SYN_RESOLUTION_MATCH: + continue + + possible_values.extend([item['value']['name'] + for item + in entry['values']]) + + # If there is only one match use the resolved value, otherwise the + # resolution cannot be determined, so use the spoken slot value + if len(possible_values) == 1: + resolved_value = possible_values[0] + else: + _LOGGER.debug( + 'Found multiple synonym resolutions for slot value: {%s: %s}', + key, + request['value'] + ) + + return resolved_value + + class AlexaResponse(object): """Help generating the response for Alexa.""" @@ -135,28 +173,17 @@ class AlexaResponse(object): self.session_attributes = {} self.should_end_session = True self.variables = {} + # Intent is None if request was a LaunchRequest or SessionEndedRequest if intent_info is not None: for key, value in intent_info.get('slots', {}).items(): - underscored_key = key.replace('.', '_') - - if 'value' in value: - self.variables[underscored_key] = value['value'] - - if 'resolutions' in value: - self._populate_resolved_values(underscored_key, value) - - def _populate_resolved_values(self, underscored_key, value): - for resolution in value['resolutions']['resolutionsPerAuthority']: - if 'values' not in resolution: - continue - - for resolved in resolution['values']: - if 'value' not in resolved: + # Only include slots with values + if 'value' not in value: continue - if 'name' in resolved['value']: - self.variables[underscored_key] = resolved['value']['name'] + _key = key.replace('.', '_') + + self.variables[_key] = resolve_slot_synonyms(key, value) def add_card(self, card_type, title, content): """Add a card to the response.""" diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 097c91ded79..a3587622b3d 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -14,6 +14,7 @@ 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" AUTHORITY_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.ZODIAC" +BUILTIN_AUTH_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.TEST" # pylint: disable=invalid-name calls = [] @@ -209,7 +210,7 @@ def test_intent_request_with_slots(alexa_client): @asyncio.coroutine -def test_intent_request_with_slots_and_name_resolution(alexa_client): +def test_intent_request_with_slots_and_synonym_resolution(alexa_client): """Test a request with slots and a name synonym.""" data = { "version": "1.0", @@ -239,7 +240,7 @@ def test_intent_request_with_slots_and_name_resolution(alexa_client): "slots": { "ZodiacSign": { "name": "ZodiacSign", - "value": "virgo", + "value": "V zodiac", "resolutions": { "resolutionsPerAuthority": [ { @@ -254,6 +255,19 @@ def test_intent_request_with_slots_and_name_resolution(alexa_client): } } ] + }, + { + "authority": BUILTIN_AUTH_ID, + "status": { + "code": "ER_SUCCESS_NO_MATCH" + }, + "values": [ + { + "value": { + "name": "Test" + } + } + ] } ] } @@ -270,6 +284,81 @@ def test_intent_request_with_slots_and_name_resolution(alexa_client): assert text == "You told us your sign is Virgo." +@asyncio.coroutine +def test_intent_request_with_slots_and_multi_synonym_resolution(alexa_client): + """Test a request with slots and multiple name synonyms.""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID + }, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False + } + }, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + } + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "V zodiac", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": { + "code": "ER_SUCCESS_MATCH" + }, + "values": [ + { + "value": { + "name": "Virgo" + } + } + ] + }, + { + "authority": BUILTIN_AUTH_ID, + "status": { + "code": "ER_SUCCESS_MATCH" + }, + "values": [ + { + "value": { + "name": "Test" + } + } + ] + } + ] + } + } + } + } + } + } + req = yield from _intent_req(alexa_client, data) + assert req.status == 200 + data = yield from req.json() + text = data.get("response", {}).get("outputSpeech", + {}).get("text") + assert text == "You told us your sign is V zodiac." + + @asyncio.coroutine def test_intent_request_with_slots_but_no_value(alexa_client): """Test a request with slots but no value.""" @@ -300,7 +389,7 @@ def test_intent_request_with_slots_but_no_value(alexa_client): "name": "GetZodiacHoroscopeIntent", "slots": { "ZodiacSign": { - "name": "ZodiacSign", + "name": "ZodiacSign" } } } From 5c20cc32b57910abf191e1bdbf0cd6b0f7362573 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Nov 2017 22:03:31 -0800 Subject: [PATCH 125/137] Update frontend to 20171117.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index d1f35683e95..094037152d4 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171115.0'] +REQUIREMENTS = ['home-assistant-frontend==20171117.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 2c1112ae49e..64d4404f45a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -331,7 +331,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171115.0 +home-assistant-frontend==20171117.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 201da6be2b3..b1ae935ff72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171115.0 +home-assistant-frontend==20171117.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 24aeea5ca33449e4590b919e8e15388e25ca4902 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 17 Nov 2017 07:05:08 +0100 Subject: [PATCH 126/137] Adjust logging in downloader component (#10622) --- homeassistant/components/downloader.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 5c9ced1fd89..d832bbdfdd1 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -77,8 +77,13 @@ def setup(hass, config): req = requests.get(url, stream=True, timeout=10) - if req.status_code == 200: + if req.status_code != 200: + _LOGGER.warning( + "downloading '%s' failed, stauts_code=%d", + url, + req.status_code) + else: if filename is None and \ 'content-disposition' in req.headers: match = re.findall(r"filename=(\S+)", @@ -121,13 +126,13 @@ def setup(hass, config): final_path = "{}_{}.{}".format(path, tries, ext) - _LOGGER.info("%s -> %s", url, final_path) + _LOGGER.debug("%s -> %s", url, final_path) with open(final_path, 'wb') as fil: for chunk in req.iter_content(1024): fil.write(chunk) - _LOGGER.info("Downloading of %s done", url) + _LOGGER.debug("Downloading of %s done", url) except requests.exceptions.ConnectionError: _LOGGER.exception("ConnectionError occurred for %s", url) From f052a0926be0f373e20ce322f18f0515aacfdbf4 Mon Sep 17 00:00:00 2001 From: Egor Tsinko Date: Thu, 16 Nov 2017 23:06:02 -0700 Subject: [PATCH 127/137] Added sorted() to python_script (#10621) * added sorted() to python_script * fixed lint errors --- homeassistant/components/python_script.py | 1 + tests/components/test_python_script.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 75b2a1fed71..85f12a18afd 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -140,6 +140,7 @@ def execute(hass, filename, source, data=None): builtins = safe_builtins.copy() builtins.update(utility_builtins) builtins['datetime'] = datetime + builtins['sorted'] = sorted builtins['time'] = TimeWrapper() builtins['dt_util'] = dt_util restricted_globals = { diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index e5d6b0c4aad..8a7f94d7dcd 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -209,6 +209,27 @@ hass.states.set('hello.ab_list', '{}'.format(ab_list)) assert caplog.text == '' +@asyncio.coroutine +def test_execute_sorted(hass, caplog): + """Test sorted() function.""" + caplog.set_level(logging.ERROR) + source = """ +a = sorted([3,1,2]) +assert(a == [1,2,3]) +hass.states.set('hello.a', a[0]) +hass.states.set('hello.b', a[1]) +hass.states.set('hello.c', a[2]) +""" + 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.c', '3') + # No errors logged = good + assert caplog.text == '' + + @asyncio.coroutine def test_exposed_modules(hass, caplog): """Test datetime and time modules exposed.""" From 1bb37aff0cfd809b7bbff4c4631f8d6a6c09c63f Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Fri, 17 Nov 2017 07:07:08 +0100 Subject: [PATCH 128/137] Add loglinefetch for frontend API call (#10579) * Add loglinefetch for frontend API call * Too many blank lines * Review changes * review changes * Only return a text * Use aiohttp * Don't do I/O in event loop * Move lines to query and default to 0 * Small fixes --- homeassistant/components/config/zwave.py | 38 ++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index dd8552f374e..c839ab7bc6e 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -2,6 +2,8 @@ import asyncio import logging +from collections import deque +from aiohttp.web import Response import homeassistant.core as ha from homeassistant.const import HTTP_NOT_FOUND, HTTP_OK from homeassistant.components.http import HomeAssistantView @@ -12,7 +14,6 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) CONFIG_PATH = 'zwave_device_config.yaml' OZW_LOG_FILENAME = 'OZW_Log.txt' -URL_API_OZW_LOG = '/api/zwave/ozwlog' @asyncio.coroutine @@ -26,13 +27,44 @@ def async_setup(hass): hass.http.register_view(ZWaveNodeGroupView) hass.http.register_view(ZWaveNodeConfigView) hass.http.register_view(ZWaveUserCodeView) - hass.http.register_static_path( - URL_API_OZW_LOG, hass.config.path(OZW_LOG_FILENAME), False) + hass.http.register_view(ZWaveLogView) hass.http.register_view(ZWaveConfigWriteView) return True +class ZWaveLogView(HomeAssistantView): + """View to read the ZWave log file.""" + + url = "/api/zwave/ozwlog" + name = "api:zwave:ozwlog" + +# pylint: disable=no-self-use + @asyncio.coroutine + def get(self, request): + """Retrieve the lines from ZWave log.""" + try: + lines = int(request.query.get('lines', 0)) + except ValueError: + return Response(text='Invalid datetime', status=400) + + hass = request.app['hass'] + response = yield from hass.async_add_job(self._get_log, hass, lines) + + return Response(text='\n'.join(response)) + + def _get_log(self, hass, lines): + """Retrieve the logfile content.""" + logfilepath = hass.config.path(OZW_LOG_FILENAME) + with open(logfilepath, 'r') as logfile: + data = (line.rstrip() for line in logfile) + if lines == 0: + loglines = list(data) + else: + loglines = deque(data, lines) + return loglines + + class ZWaveConfigWriteView(HomeAssistantView): """View to save the ZWave configuration to zwcfg_xxxxx.xml.""" From 62c8843956bf3283c728144ad4422367ead3e213 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Nov 2017 22:08:15 -0800 Subject: [PATCH 129/137] Update frontend to 20171117.1 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 094037152d4..bac00b8a57a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171117.0'] +REQUIREMENTS = ['home-assistant-frontend==20171117.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 64d4404f45a..04432aa26a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -331,7 +331,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171117.0 +home-assistant-frontend==20171117.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1ae935ff72..32c2feb7427 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171117.0 +home-assistant-frontend==20171117.1 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From eb8a8f6d0b13780ecf967fa0b43d83091f86dcef Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Nov 2017 22:10:40 -0800 Subject: [PATCH 130/137] Version bump to 0.58.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d08308de820..d8b4dfcb044 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 58 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From b3d66e5881a2b54dc90c5899a6f23e0ede209f7e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 17 Nov 2017 19:09:47 -0800 Subject: [PATCH 131/137] Update frontend to 20171118.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index bac00b8a57a..e7cfcf8d88c 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171117.1'] +REQUIREMENTS = ['home-assistant-frontend==20171118.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 04432aa26a2..004535fd14f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -331,7 +331,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171117.1 +home-assistant-frontend==20171118.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32c2feb7427..c9ea20494d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171117.1 +home-assistant-frontend==20171118.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 0202e966ea250188ef4da3e4a0b29d929060e912 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 17 Nov 2017 13:11:05 -0700 Subject: [PATCH 132/137] Fixes AirVisual bug regarding incorrect location data (#10054) * Fixes AirVisual bug regarding incorrect location data * Owner-requested changes --- homeassistant/components/sensor/airvisual.py | 47 ++++++++++---------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 56ddf7adcab..5ea24dab823 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -126,7 +126,7 @@ class AirVisualBaseSensor(Entity): def __init__(self, data, name, icon, locale): """Initialize the sensor.""" - self._data = data + self.data = data self._icon = icon self._locale = locale self._name = name @@ -136,20 +136,17 @@ class AirVisualBaseSensor(Entity): @property def device_state_attributes(self): """Return the device state attributes.""" - attrs = { + attrs = merge_two_dicts({ ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_CITY: self._data.city, - ATTR_COUNTRY: self._data.country, - ATTR_REGION: self._data.state, - ATTR_TIMESTAMP: self._data.pollution_info.get('ts') - } + ATTR_TIMESTAMP: self.data.pollution_info.get('ts') + }, self.data.attrs) - if self._data.show_on_map: - attrs[ATTR_LATITUDE] = self._data.latitude - attrs[ATTR_LONGITUDE] = self._data.longitude + if self.data.show_on_map: + attrs[ATTR_LATITUDE] = self.data.latitude + attrs[ATTR_LONGITUDE] = self.data.longitude else: - attrs['lati'] = self._data.latitude - attrs['long'] = self._data.longitude + attrs['lati'] = self.data.latitude + attrs['long'] = self.data.longitude return attrs @@ -174,9 +171,9 @@ class AirPollutionLevelSensor(AirVisualBaseSensor): def update(self): """Update the status of the sensor.""" - self._data.update() + self.data.update() - aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + aqi = self.data.pollution_info.get('aqi{0}'.format(self._locale)) try: [level] = [ i for i in POLLUTANT_LEVEL_MAPPING @@ -199,9 +196,9 @@ class AirQualityIndexSensor(AirVisualBaseSensor): def update(self): """Update the status of the sensor.""" - self._data.update() + self.data.update() - self._state = self._data.pollution_info.get( + self._state = self.data.pollution_info.get( 'aqi{0}'.format(self._locale)) @@ -224,9 +221,9 @@ class MainPollutantSensor(AirVisualBaseSensor): def update(self): """Update the status of the sensor.""" - self._data.update() + self.data.update() - symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + 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') @@ -239,6 +236,7 @@ class AirVisualData(object): def __init__(self, client, **kwargs): """Initialize the AirVisual data element.""" self._client = client + self.attrs = {} self.pollution_info = None self.city = kwargs.get(CONF_CITY) @@ -260,17 +258,20 @@ class AirVisualData(object): if self.city and self.state and self.country: resp = self._client.city( self.city, self.state, self.country).get('data') + self.longitude, self.latitude = resp.get('location').get( + 'coordinates') 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.longitude, self.latitude = resp.get('location').get( - 'coordinates') self.pollution_info = resp.get('current', {}).get('pollution', {}) + + self.attrs = { + ATTR_CITY: resp.get('city'), + ATTR_REGION: resp.get('state'), + ATTR_COUNTRY: resp.get('country') + } except exceptions.HTTPError as exc_info: _LOGGER.error("Unable to retrieve data on this location: %s", self.__dict__) From bf8e2bd77ee743624a4f1a15936fa4885857f8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cezar=20S=C3=A1=20Espinola?= Date: Fri, 17 Nov 2017 16:29:23 -0200 Subject: [PATCH 133/137] Make MQTT reconnection logic more resilient and fix race condition (#10133) --- homeassistant/components/mqtt/__init__.py | 34 ++++++++--------------- tests/components/mqtt/test_init.py | 28 ++++++++++++------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9decc9a14aa..3a6abec0ddf 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -438,7 +438,8 @@ class MQTT(object): self.broker = broker self.port = port self.keepalive = keepalive - self.topics = {} + self.wanted_topics = {} + self.subscribed_topics = {} self.progress = {} self.birth_message = birth_message self._mqttc = None @@ -526,15 +527,14 @@ class MQTT(object): raise HomeAssistantError("topic need to be a string!") with (yield from self._paho_lock): - if topic in self.topics: + if topic in self.subscribed_topics: return - + self.wanted_topics[topic] = qos result, mid = yield from self.hass.async_add_job( self._mqttc.subscribe, topic, qos) _raise_on_error(result) self.progress[mid] = topic - self.topics[topic] = None @asyncio.coroutine def async_unsubscribe(self, topic): @@ -542,6 +542,7 @@ class MQTT(object): This method is a coroutine. """ + self.wanted_topics.pop(topic, None) result, mid = yield from self.hass.async_add_job( self._mqttc.unsubscribe, topic) @@ -562,15 +563,10 @@ class MQTT(object): self._mqttc.disconnect() return - old_topics = self.topics - - self.topics = {key: value for key, value in self.topics.items() - if value is None} - - for topic, qos in old_topics.items(): - # qos is None if we were in process of subscribing - if qos is not None: - self.hass.add_job(self.async_subscribe, topic, qos) + self.progress = {} + self.subscribed_topics = {} + for topic, qos in self.wanted_topics.items(): + self.hass.add_job(self.async_subscribe, topic, qos) if self.birth_message: self.hass.add_job(self.async_publish( @@ -584,7 +580,7 @@ class MQTT(object): topic = self.progress.pop(mid, None) if topic is None: return - self.topics[topic] = granted_qos[0] + self.subscribed_topics[topic] = granted_qos[0] def _mqtt_on_message(self, _mqttc, _userdata, msg): """Message received callback.""" @@ -598,18 +594,12 @@ class MQTT(object): topic = self.progress.pop(mid, None) if topic is None: return - self.topics.pop(topic, None) + self.subscribed_topics.pop(topic, None) def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code): """Disconnected callback.""" self.progress = {} - self.topics = {key: value for key, value in self.topics.items() - if value is not None} - - # Remove None values from topic list - for key in list(self.topics): - if self.topics[key] is None: - self.topics.pop(key) + self.subscribed_topics = {} # When disconnected because of calling disconnect() if result_code == 0: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 3d068224243..55ff0e9ff05 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -388,9 +388,12 @@ class TestMQTTCallbacks(unittest.TestCase): @mock.patch('homeassistant.components.mqtt.time.sleep') def test_mqtt_disconnect_tries_reconnect(self, mock_sleep): """Test the re-connect tries.""" - self.hass.data['mqtt'].topics = { + self.hass.data['mqtt'].subscribed_topics = { 'test/topic': 1, - 'test/progress': None + } + self.hass.data['mqtt'].wanted_topics = { + 'test/progress': 0, + 'test/topic': 2, } self.hass.data['mqtt'].progress = { 1: 'test/progress' @@ -403,7 +406,9 @@ class TestMQTTCallbacks(unittest.TestCase): self.assertEqual([1, 2, 4], [call[1][0] for call in mock_sleep.mock_calls]) - self.assertEqual({'test/topic': 1}, self.hass.data['mqtt'].topics) + self.assertEqual({'test/topic': 2, 'test/progress': 0}, + self.hass.data['mqtt'].wanted_topics) + self.assertEqual({}, self.hass.data['mqtt'].subscribed_topics) self.assertEqual({}, self.hass.data['mqtt'].progress) def test_invalid_mqtt_topics(self): @@ -556,12 +561,15 @@ def test_mqtt_subscribes_topics_on_connect(hass): """Test subscription to topic on connect.""" mqtt_client = yield from mock_mqtt_client(hass) - prev_topics = OrderedDict() - prev_topics['topic/test'] = 1, - prev_topics['home/sensor'] = 2, - prev_topics['still/pending'] = None + subscribed_topics = OrderedDict() + subscribed_topics['topic/test'] = 1 + subscribed_topics['home/sensor'] = 2 - hass.data['mqtt'].topics = prev_topics + wanted_topics = subscribed_topics.copy() + wanted_topics['still/pending'] = 0 + + hass.data['mqtt'].wanted_topics = wanted_topics + hass.data['mqtt'].subscribed_topics = subscribed_topics hass.data['mqtt'].progress = {1: 'still/pending'} # Return values for subscribe calls (rc, mid) @@ -574,7 +582,7 @@ def test_mqtt_subscribes_topics_on_connect(hass): assert not mqtt_client.disconnect.called - expected = [(topic, qos) for topic, qos in prev_topics.items() - if qos is not None] + expected = [(topic, qos) for topic, qos in wanted_topics.items()] assert [call[1][1:] for call in hass.add_job.mock_calls] == expected + assert hass.data['mqtt'].progress == {} From e449ceeeff8ebe0392653a2e4f7833d8e01d8c51 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 17 Nov 2017 09:14:22 -0800 Subject: [PATCH 134/137] Alexa improvements (#10632) * Initial scene support * Initial fan support * ordering * Initial lock support * Scenes cant be deactivated; Correct the scene display category * Initial input_boolean support * Support customization of Alexa discovered entities * Initial media player support * Add input_boolean to tests * Add play/pause/stop/next/previous to media player * Add missing functions and pylint * Set manufacturerName to Home Assistant since the value is displayed in app * Add scene test * Add fan tests * Add lock test * Fix volume logic * Add volume tests * settup -> setup * Remove unused variable * Set required scene description as per docs * Allow setting scene category (ACTIVITY_TRIGGER/SCENE_TRIGGER) * Add alert, automation and group support/tests * Change display categories to match docs * simplify down the display category props into a single prop which can be used on any entity * Fix tests to expect proper display categories * Add cover support * sort things * Use generic homeassistant domain for turn on/off --- homeassistant/components/alexa/smart_home.py | 334 +++++++++++- tests/components/alexa/test_smart_home.py | 546 ++++++++++++++++++- 2 files changed, 853 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a96386cbdf9..c5a849ad560 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -4,9 +4,16 @@ import logging import math from uuid import uuid4 +import homeassistant.core as ha from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) -from homeassistant.components import switch, light, script + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_LOCK, + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_UNLOCK, SERVICE_VOLUME_SET) +from homeassistant.components import ( + alert, automation, cover, fan, group, input_boolean, light, lock, + media_player, scene, script, switch) import homeassistant.util.color as color_util from homeassistant.util.decorator import Registry @@ -14,15 +21,32 @@ HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) API_DIRECTIVE = 'directive' +API_ENDPOINT = 'endpoint' API_EVENT = 'event' API_HEADER = 'header' API_PAYLOAD = 'payload' -API_ENDPOINT = 'endpoint' + +ATTR_ALEXA_DESCRIPTION = 'alexa_description' +ATTR_ALEXA_DISPLAY_CATEGORIES = 'alexa_display_categories' +ATTR_ALEXA_HIDDEN = 'alexa_hidden' +ATTR_ALEXA_NAME = 'alexa_name' MAPPING_COMPONENT = { - script.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], - switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], + alert.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], + automation.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], + cover.DOMAIN: [ + 'DOOR', ('Alexa.PowerController',), { + cover.SUPPORT_SET_POSITION: 'Alexa.PercentageController', + } + ], + fan.DOMAIN: [ + 'OTHER', ('Alexa.PowerController',), { + fan.SUPPORT_SET_SPEED: 'Alexa.PercentageController', + } + ], + group.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], + input_boolean.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], light.DOMAIN: [ 'LIGHT', ('Alexa.PowerController',), { light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController', @@ -31,6 +55,20 @@ MAPPING_COMPONENT = { light.SUPPORT_COLOR_TEMP: 'Alexa.ColorTemperatureController', } ], + lock.DOMAIN: ['SMARTLOCK', ('Alexa.LockController',), None], + media_player.DOMAIN: [ + 'TV', ('Alexa.PowerController',), { + media_player.SUPPORT_VOLUME_SET: 'Alexa.Speaker', + media_player.SUPPORT_PLAY: 'Alexa.PlaybackController', + media_player.SUPPORT_PAUSE: 'Alexa.PlaybackController', + media_player.SUPPORT_STOP: 'Alexa.PlaybackController', + media_player.SUPPORT_NEXT_TRACK: 'Alexa.PlaybackController', + media_player.SUPPORT_PREVIOUS_TRACK: 'Alexa.PlaybackController', + } + ], + scene.DOMAIN: ['ACTIVITY_TRIGGER', ('Alexa.SceneController',), None], + script.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], + switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], } @@ -108,18 +146,33 @@ def async_api_discovery(hass, request): discovery_endpoints = [] for entity in hass.states.async_all(): + if entity.attributes.get(ATTR_ALEXA_HIDDEN, False): + continue + class_data = MAPPING_COMPONENT.get(entity.domain) if not class_data: continue + friendly_name = entity.attributes.get(ATTR_ALEXA_NAME, entity.name) + description = entity.attributes.get(ATTR_ALEXA_DESCRIPTION, + entity.entity_id) + + # Required description as per Amazon Scene docs + if entity.domain == scene.DOMAIN: + scene_fmt = '%s (Scene connected via Home Assistant)' + description = scene_fmt.format(description) + + cat_key = ATTR_ALEXA_DISPLAY_CATEGORIES + display_categories = entity.attributes.get(cat_key, class_data[0]) + endpoint = { - 'displayCategories': [class_data[0]], + 'displayCategories': [display_categories], 'additionalApplianceDetails': {}, 'endpointId': entity.entity_id.replace('.', '#'), - 'friendlyName': entity.name, - 'description': '', - 'manufacturerName': 'Unknown', + 'friendlyName': friendly_name, + 'description': description, + 'manufacturerName': 'Home Assistant', } actions = set() @@ -175,7 +228,7 @@ def extract_entity(funct): @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, { + yield from hass.services.async_call(ha.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id }, blocking=True) @@ -187,7 +240,7 @@ def async_api_turn_on(hass, request, 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, { + yield from hass.services.async_call(ha.DOMAIN, SERVICE_TURN_OFF, { ATTR_ENTITY_ID: entity.entity_id }, blocking=True) @@ -310,3 +363,262 @@ def async_api_increase_color_temp(hass, request, entity): }, blocking=True) return api_message(request) + + +@HANDLERS.register(('Alexa.SceneController', 'Activate')) +@extract_entity +@asyncio.coroutine +def async_api_activate(hass, request, entity): + """Process a activate request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) +@extract_entity +@asyncio.coroutine +def async_api_set_percentage(hass, request, entity): + """Process a set percentage request.""" + percentage = int(request[API_PAYLOAD]['percentage']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = percentage + + yield from hass.services.async_call(entity.domain, service, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_percentage(hass, request, entity): + """Process a adjust percentage request.""" + percentage_delta = int(request[API_PAYLOAD]['percentageDelta']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = entity.attributes.get(fan.ATTR_SPEED) + + if speed == "off": + current = 0 + elif speed == "low": + current = 33 + elif speed == "medium": + current = 66 + elif speed == "high": + current = 100 + + # set percentage + percentage = max(0, percentage_delta + current) + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + + current = entity.attributes.get(cover.ATTR_POSITION) + + data[cover.ATTR_POSITION] = max(0, percentage_delta + current) + + yield from hass.services.async_call(entity.domain, service, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.LockController', 'Lock')) +@extract_entity +@asyncio.coroutine +def async_api_lock(hass, request, entity): + """Process a lock request.""" + yield from hass.services.async_call(entity.domain, SERVICE_LOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +# Not supported by Alexa yet +@HANDLERS.register(('Alexa.LockController', 'Unlock')) +@extract_entity +@asyncio.coroutine +def async_api_unlock(hass, request, entity): + """Process a unlock request.""" + yield from hass.services.async_call(entity.domain, SERVICE_UNLOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) +@extract_entity +@asyncio.coroutine +def async_api_set_volume(hass, request, entity): + """Process a set volume request.""" + volume = round(float(request[API_PAYLOAD]['volume'] / 100), 2) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + yield from hass.services.async_call(entity.domain, SERVICE_VOLUME_SET, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_volume(hass, request, entity): + """Process a adjust volume request.""" + volume_delta = int(request[API_PAYLOAD]['volume']) + + current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) + + # read current state + try: + current = math.floor(int(current_level * 100)) + except ZeroDivisionError: + current = 0 + + volume = float(max(0, volume_delta + current) / 100) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + yield from hass.services.async_call(entity.domain, + media_player.SERVICE_VOLUME_SET, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.Speaker', 'SetMute')) +@extract_entity +@asyncio.coroutine +def async_api_set_mute(hass, request, entity): + """Process a set mute request.""" + mute = bool(request[API_PAYLOAD]['mute']) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_MUTED: mute, + } + + yield from hass.services.async_call(entity.domain, + media_player.SERVICE_VOLUME_MUTE, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Play')) +@extract_entity +@asyncio.coroutine +def async_api_play(hass, request, entity): + """Process a play request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_PLAY, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) +@extract_entity +@asyncio.coroutine +def async_api_pause(hass, request, entity): + """Process a pause request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_PAUSE, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) +@extract_entity +@asyncio.coroutine +def async_api_stop(hass, request, entity): + """Process a stop request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_STOP, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Next')) +@extract_entity +@asyncio.coroutine +def async_api_next(hass, request, entity): + """Process a next request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, + SERVICE_MEDIA_NEXT_TRACK, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) +@extract_entity +@asyncio.coroutine +def async_api_previous(hass, request, entity): + """Process a previous request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, + SERVICE_MEDIA_PREVIOUS_TRACK, + data, blocking=True) + + return api_message(request) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index eadb72f91c0..3fe9145f2d6 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -99,7 +99,7 @@ def test_discovery_request(hass): """Test alexa discovery request.""" request = get_new_request('Alexa.Discovery', 'Discover') - # settup test devices + # setup test devices hass.states.async_set( 'switch.test', 'on', {'friendly_name': "Test switch"}) @@ -117,12 +117,52 @@ def test_discovery_request(hass): hass.states.async_set( 'script.test', 'off', {'friendly_name': "Test script"}) + hass.states.async_set( + 'input_boolean.test', 'off', {'friendly_name': "Test input boolean"}) + + hass.states.async_set( + 'scene.test', 'off', {'friendly_name': "Test scene"}) + + hass.states.async_set( + 'fan.test_1', 'off', {'friendly_name': "Test fan 1"}) + + hass.states.async_set( + 'fan.test_2', 'off', { + 'friendly_name': "Test fan 2", 'supported_features': 1, + 'speed_list': ['low', 'medium', 'high'] + }) + + hass.states.async_set( + 'lock.test', 'off', {'friendly_name': "Test lock"}) + + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", + 'supported_features': 20925, + 'volume_level': 1 + }) + + hass.states.async_set( + 'alert.test', 'off', {'friendly_name': "Test alert"}) + + hass.states.async_set( + 'automation.test', 'off', {'friendly_name': "Test automation"}) + + hass.states.async_set( + 'group.test', 'off', {'friendly_name': "Test group"}) + + hass.states.async_set( + 'cover.test', 'off', { + 'friendly_name': "Test cover", 'supported_features': 255, + 'position': 85 + }) + msg = yield from smart_home.async_handle_message(hass, request) assert 'event' in msg msg = msg['event'] - assert len(msg['payload']['endpoints']) == 5 + assert len(msg['payload']['endpoints']) == 15 assert msg['header']['name'] == 'Discover.Response' assert msg['header']['namespace'] == 'Alexa.Discovery' @@ -174,13 +214,108 @@ def test_discovery_request(hass): continue if appliance['endpointId'] == 'script#test': - assert appliance['displayCategories'][0] == "SWITCH" + assert appliance['displayCategories'][0] == "OTHER" assert appliance['friendlyName'] == "Test script" assert len(appliance['capabilities']) == 1 assert appliance['capabilities'][-1]['interface'] == \ 'Alexa.PowerController' continue + if appliance['endpointId'] == 'input_boolean#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test input boolean" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'scene#test': + assert appliance['displayCategories'][0] == "ACTIVITY_TRIGGER" + assert appliance['friendlyName'] == "Test scene" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.SceneController' + continue + + if appliance['endpointId'] == 'fan#test_1': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test fan 1" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'fan#test_2': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test fan 2" + assert len(appliance['capabilities']) == 2 + + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.PercentageController' in caps + assert 'Alexa.PowerController' in caps + continue + + if appliance['endpointId'] == 'lock#test': + assert appliance['displayCategories'][0] == "SMARTLOCK" + assert appliance['friendlyName'] == "Test lock" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.LockController' + continue + + if appliance['endpointId'] == 'media_player#test': + assert appliance['displayCategories'][0] == "TV" + assert appliance['friendlyName'] == "Test media player" + assert len(appliance['capabilities']) == 3 + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.PowerController' in caps + assert 'Alexa.Speaker' in caps + assert 'Alexa.PlaybackController' in caps + continue + + if appliance['endpointId'] == 'alert#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test alert" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'automation#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test automation" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'group#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test group" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'cover#test': + assert appliance['displayCategories'][0] == "DOOR" + assert appliance['friendlyName'] == "Test cover" + assert len(appliance['capabilities']) == 2 + + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.PercentageController' in caps + assert 'Alexa.PowerController' in caps + continue + raise AssertionError("Unknown appliance!") @@ -217,19 +352,21 @@ def test_api_function_not_implemented(hass): @asyncio.coroutine -@pytest.mark.parametrize("domain", ['light', 'switch', 'script']) +@pytest.mark.parametrize("domain", ['alert', 'automation', 'group', + 'input_boolean', 'light', 'script', + 'switch']) def test_api_turn_on(hass, domain): """Test api turn on process.""" request = get_new_request( 'Alexa.PowerController', 'TurnOn', '{}#test'.format(domain)) - # settup test devices + # setup test devices hass.states.async_set( '{}.test'.format(domain), 'off', { 'friendly_name': "Test {}".format(domain) }) - call = async_mock_service(hass, domain, 'turn_on') + call = async_mock_service(hass, 'homeassistant', 'turn_on') msg = yield from smart_home.async_handle_message(hass, request) @@ -242,19 +379,21 @@ def test_api_turn_on(hass, domain): @asyncio.coroutine -@pytest.mark.parametrize("domain", ['light', 'switch', 'script']) +@pytest.mark.parametrize("domain", ['alert', 'automation', 'group', + 'input_boolean', 'light', 'script', + 'switch']) def test_api_turn_off(hass, domain): """Test api turn on process.""" request = get_new_request( 'Alexa.PowerController', 'TurnOff', '{}#test'.format(domain)) - # settup test devices + # setup test devices hass.states.async_set( '{}.test'.format(domain), 'on', { 'friendly_name': "Test {}".format(domain) }) - call = async_mock_service(hass, domain, 'turn_off') + call = async_mock_service(hass, 'homeassistant', 'turn_off') msg = yield from smart_home.async_handle_message(hass, request) @@ -275,7 +414,7 @@ def test_api_set_brightness(hass): # add payload request['directive']['payload']['brightness'] = '50' - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', {'friendly_name': "Test light"}) @@ -303,7 +442,7 @@ def test_api_adjust_brightness(hass, result, adjust): # add payload request['directive']['payload']['brightnessDelta'] = adjust - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", 'brightness': '77' @@ -335,7 +474,7 @@ def test_api_set_color_rgb(hass): 'brightness': '0.342', } - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", @@ -368,7 +507,7 @@ def test_api_set_color_xy(hass): 'brightness': '0.342', } - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", @@ -399,7 +538,7 @@ def test_api_set_color_temperature(hass): # add payload request['directive']['payload']['colorTemperatureInKelvin'] = '7500' - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', {'friendly_name': "Test light"}) @@ -424,7 +563,7 @@ def test_api_decrease_color_temp(hass, result, initial): 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', 'light#test') - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", 'color_temp': initial, @@ -452,7 +591,7 @@ def test_api_increase_color_temp(hass, result, initial): 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', 'light#test') - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", 'color_temp': initial, @@ -470,3 +609,378 @@ def test_api_increase_color_temp(hass, result, initial): assert call_light[0].data['entity_id'] == 'light.test' assert call_light[0].data['color_temp'] == result assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['scene']) +def test_api_activate(hass, domain): + """Test api activate process.""" + request = get_new_request( + 'Alexa.SceneController', 'Activate', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'turn_on') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_percentage_fan(hass): + """Test api set percentage for fan process.""" + request = get_new_request( + 'Alexa.PercentageController', 'SetPercentage', 'fan#test_2') + + # add payload + request['directive']['payload']['percentage'] = '50' + + # setup test devices + hass.states.async_set( + 'fan.test_2', 'off', {'friendly_name': "Test fan"}) + + call_fan = async_mock_service(hass, 'fan', 'set_speed') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_fan) == 1 + assert call_fan[0].data['entity_id'] == 'fan.test_2' + assert call_fan[0].data['speed'] == 'medium' + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_percentage_cover(hass): + """Test api set percentage for cover process.""" + request = get_new_request( + 'Alexa.PercentageController', 'SetPercentage', 'cover#test') + + # add payload + request['directive']['payload']['percentage'] = '50' + + # setup test devices + hass.states.async_set( + 'cover.test', 'closed', { + 'friendly_name': "Test cover" + }) + + call_cover = async_mock_service(hass, 'cover', 'set_cover_position') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_cover) == 1 + assert call_cover[0].data['entity_id'] == 'cover.test' + assert call_cover[0].data['position'] == 50 + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize( + "result,adjust", [('high', '-5'), ('off', '5'), ('low', '-80')]) +def test_api_adjust_percentage_fan(hass, result, adjust): + """Test api adjust percentage for fan process.""" + request = get_new_request( + 'Alexa.PercentageController', 'AdjustPercentage', 'fan#test_2') + + # add payload + request['directive']['payload']['percentageDelta'] = adjust + + # setup test devices + hass.states.async_set( + 'fan.test_2', 'on', { + 'friendly_name': "Test fan 2", 'speed': 'high' + }) + + call_fan = async_mock_service(hass, 'fan', 'set_speed') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_fan) == 1 + assert call_fan[0].data['entity_id'] == 'fan.test_2' + assert call_fan[0].data['speed'] == result + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize( + "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) +def test_api_adjust_percentage_cover(hass, result, adjust): + """Test api adjust percentage for cover process.""" + request = get_new_request( + 'Alexa.PercentageController', 'AdjustPercentage', 'cover#test') + + # add payload + request['directive']['payload']['percentageDelta'] = adjust + + # setup test devices + hass.states.async_set( + 'cover.test', 'closed', { + 'friendly_name': "Test cover", + 'position': 30 + }) + + call_cover = async_mock_service(hass, 'cover', 'set_cover_position') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_cover) == 1 + assert call_cover[0].data['entity_id'] == 'cover.test' + assert call_cover[0].data['position'] == result + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['lock']) +def test_api_lock(hass, domain): + """Test api lock process.""" + request = get_new_request( + 'Alexa.LockController', 'Lock', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'lock') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_play(hass, domain): + """Test api play process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Play', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_play') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_pause(hass, domain): + """Test api pause process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Pause', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_pause') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_stop(hass, domain): + """Test api stop process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Stop', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_stop') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_next(hass, domain): + """Test api next process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Next', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_next_track') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_previous(hass, domain): + """Test api previous process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Previous', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_previous_track') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_volume(hass): + """Test api set volume process.""" + request = get_new_request( + 'Alexa.Speaker', 'SetVolume', 'media_player#test') + + # add payload + request['directive']['payload']['volume'] = 50 + + # setup test devices + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", 'volume_level': 0 + }) + + call_media_player = async_mock_service(hass, 'media_player', 'volume_set') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_media_player) == 1 + assert call_media_player[0].data['entity_id'] == 'media_player.test' + assert call_media_player[0].data['volume_level'] == 0.5 + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize( + "result,adjust", [(0.7, '-5'), (0.8, '5'), (0, '-80')]) +def test_api_adjust_volume(hass, result, adjust): + """Test api adjust volume process.""" + request = get_new_request( + 'Alexa.Speaker', 'AdjustVolume', 'media_player#test') + + # add payload + request['directive']['payload']['volume'] = adjust + + # setup test devices + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", 'volume_level': 0.75 + }) + + call_media_player = async_mock_service(hass, 'media_player', 'volume_set') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_media_player) == 1 + assert call_media_player[0].data['entity_id'] == 'media_player.test' + assert call_media_player[0].data['volume_level'] == result + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_mute(hass, domain): + """Test api mute process.""" + request = get_new_request( + 'Alexa.Speaker', 'SetMute', '{}#test'.format(domain)) + + request['directive']['payload']['mute'] = True + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'volume_mute') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' From b86110a15d6d93ea06707f3b00d338b9ea8e55a1 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 17 Nov 2017 18:36:18 +0200 Subject: [PATCH 135/137] Print entity type in "too slow" warnings (#10641) * Update entity.py * Update entity.py --- homeassistant/helpers/entity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 4a967e50995..78db0890ab1 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -235,10 +235,10 @@ class Entity(object): if not self._slow_reported and end - start > 0.4: self._slow_reported = True - _LOGGER.warning("Updating state for %s took %.3f seconds. " + _LOGGER.warning("Updating state for %s (%s) took %.3f seconds. " "Please report platform to the developers at " "https://goo.gl/Nvioub", self.entity_id, - end - start) + type(self), end - start) # Overwrite properties that have been set in the config file. if DATA_CUSTOMIZE in self.hass.data: From 35699273da5dabbf48659f56020250cd54a90862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Sat, 18 Nov 2017 00:00:15 +0100 Subject: [PATCH 136/137] Bump pyatv to 0.3.8 (#10643) Fixes AirPlay issues on newer versions of tvOS. --- homeassistant/components/apple_tv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 6e38f172c4c..c8eb1841c0d 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.6'] +REQUIREMENTS = ['pyatv==0.3.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 004535fd14f..405bafaf13a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -612,7 +612,7 @@ pyasn1-modules==0.1.5 pyasn1==0.3.7 # homeassistant.components.apple_tv -pyatv==0.3.6 +pyatv==0.3.8 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox From 425c027085f859adb0e650aa2a15bbd689ccd537 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 17 Nov 2017 21:10:24 -0800 Subject: [PATCH 137/137] Implement entity and domain exclude/include for Alexa (#10647) * Implement entity and domain exclude/include for Alexa * Switch to using generate_filter * Use proper domain for turn on/off calls except for groups where we must use the generic homeassistant.turn_on/off * travis fixes * Untangle * Lint --- homeassistant/components/alexa/smart_home.py | 75 +++++---- homeassistant/components/cloud/__init__.py | 19 ++- homeassistant/components/cloud/iot.py | 4 +- homeassistant/components/mqtt_statestream.py | 12 +- homeassistant/helpers/config_validation.py | 16 +- homeassistant/helpers/entityfilter.py | 24 +++ tests/components/alexa/test_smart_home.py | 158 +++++++++++++++---- 7 files changed, 233 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index c5a849ad560..6e71fc67df1 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,5 +1,6 @@ """Support for alexa Smart Home Skill API.""" import asyncio +from collections import namedtuple import logging import math from uuid import uuid4 @@ -72,8 +73,11 @@ MAPPING_COMPONENT = { } +Config = namedtuple('AlexaConfig', 'filter') + + @asyncio.coroutine -def async_handle_message(hass, message): +def async_handle_message(hass, config, message): """Handle incoming API messages.""" assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' @@ -89,7 +93,7 @@ def async_handle_message(hass, message): "Unsupported API request %s/%s", namespace, name) return api_error(message) - return (yield from funct_ref(hass, message)) + return (yield from funct_ref(hass, config, message)) def api_message(request, name='Response', namespace='Alexa', payload=None): @@ -138,7 +142,7 @@ def api_error(request, error_type='INTERNAL_ERROR', error_message=""): @HANDLERS.register(('Alexa.Discovery', 'Discover')) @asyncio.coroutine -def async_api_discovery(hass, request): +def async_api_discovery(hass, config, request): """Create a API formatted discovery response. Async friendly. @@ -146,7 +150,14 @@ def async_api_discovery(hass, request): discovery_endpoints = [] for entity in hass.states.async_all(): + if not config.filter(entity.entity_id): + _LOGGER.debug("Not exposing %s because filtered by config", + entity.entity_id) + continue + if entity.attributes.get(ATTR_ALEXA_HIDDEN, False): + _LOGGER.debug("Not exposing %s because alexa_hidden is true", + entity.entity_id) continue class_data = MAPPING_COMPONENT.get(entity.domain) @@ -207,7 +218,7 @@ def async_api_discovery(hass, request): def extract_entity(funct): """Decorator for extract entity object from request.""" @asyncio.coroutine - def async_api_entity_wrapper(hass, request): + def async_api_entity_wrapper(hass, config, request): """Process a turn on request.""" entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.') @@ -218,7 +229,7 @@ def extract_entity(funct): request[API_HEADER]['name'], entity_id) return api_error(request, error_type='NO_SUCH_ENDPOINT') - return (yield from funct(hass, request, entity)) + return (yield from funct(hass, config, request, entity)) return async_api_entity_wrapper @@ -226,9 +237,13 @@ def extract_entity(funct): @HANDLERS.register(('Alexa.PowerController', 'TurnOn')) @extract_entity @asyncio.coroutine -def async_api_turn_on(hass, request, entity): +def async_api_turn_on(hass, config, request, entity): """Process a turn on request.""" - yield from hass.services.async_call(ha.DOMAIN, SERVICE_TURN_ON, { + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + yield from hass.services.async_call(domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id }, blocking=True) @@ -238,9 +253,13 @@ def async_api_turn_on(hass, request, entity): @HANDLERS.register(('Alexa.PowerController', 'TurnOff')) @extract_entity @asyncio.coroutine -def async_api_turn_off(hass, request, entity): +def async_api_turn_off(hass, config, request, entity): """Process a turn off request.""" - yield from hass.services.async_call(ha.DOMAIN, SERVICE_TURN_OFF, { + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + yield from hass.services.async_call(domain, SERVICE_TURN_OFF, { ATTR_ENTITY_ID: entity.entity_id }, blocking=True) @@ -250,7 +269,7 @@ def async_api_turn_off(hass, request, entity): @HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) @extract_entity @asyncio.coroutine -def async_api_set_brightness(hass, request, entity): +def async_api_set_brightness(hass, config, request, entity): """Process a set brightness request.""" brightness = int(request[API_PAYLOAD]['brightness']) @@ -265,7 +284,7 @@ def async_api_set_brightness(hass, request, entity): @HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) @extract_entity @asyncio.coroutine -def async_api_adjust_brightness(hass, request, entity): +def async_api_adjust_brightness(hass, config, request, entity): """Process a adjust brightness request.""" brightness_delta = int(request[API_PAYLOAD]['brightnessDelta']) @@ -289,7 +308,7 @@ def async_api_adjust_brightness(hass, request, entity): @HANDLERS.register(('Alexa.ColorController', 'SetColor')) @extract_entity @asyncio.coroutine -def async_api_set_color(hass, request, entity): +def async_api_set_color(hass, config, request, entity): """Process a set color request.""" supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES) rgb = color_util.color_hsb_to_RGB( @@ -317,7 +336,7 @@ def async_api_set_color(hass, request, entity): @HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) @extract_entity @asyncio.coroutine -def async_api_set_color_temperature(hass, request, entity): +def async_api_set_color_temperature(hass, config, request, entity): """Process a set color temperature request.""" kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin']) @@ -333,7 +352,7 @@ def async_api_set_color_temperature(hass, request, entity): ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) @extract_entity @asyncio.coroutine -def async_api_decrease_color_temp(hass, request, entity): +def async_api_decrease_color_temp(hass, config, request, entity): """Process a decrease color temperature request.""" current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) @@ -351,7 +370,7 @@ def async_api_decrease_color_temp(hass, request, entity): ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) @extract_entity @asyncio.coroutine -def async_api_increase_color_temp(hass, request, entity): +def async_api_increase_color_temp(hass, config, request, entity): """Process a increase color temperature request.""" current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) @@ -368,7 +387,7 @@ def async_api_increase_color_temp(hass, request, entity): @HANDLERS.register(('Alexa.SceneController', 'Activate')) @extract_entity @asyncio.coroutine -def async_api_activate(hass, request, entity): +def async_api_activate(hass, config, request, entity): """Process a activate request.""" yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id @@ -380,7 +399,7 @@ def async_api_activate(hass, request, entity): @HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) @extract_entity @asyncio.coroutine -def async_api_set_percentage(hass, request, entity): +def async_api_set_percentage(hass, config, request, entity): """Process a set percentage request.""" percentage = int(request[API_PAYLOAD]['percentage']) service = None @@ -411,7 +430,7 @@ def async_api_set_percentage(hass, request, entity): @HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) @extract_entity @asyncio.coroutine -def async_api_adjust_percentage(hass, request, entity): +def async_api_adjust_percentage(hass, config, request, entity): """Process a adjust percentage request.""" percentage_delta = int(request[API_PAYLOAD]['percentageDelta']) service = None @@ -459,7 +478,7 @@ def async_api_adjust_percentage(hass, request, entity): @HANDLERS.register(('Alexa.LockController', 'Lock')) @extract_entity @asyncio.coroutine -def async_api_lock(hass, request, entity): +def async_api_lock(hass, config, request, entity): """Process a lock request.""" yield from hass.services.async_call(entity.domain, SERVICE_LOCK, { ATTR_ENTITY_ID: entity.entity_id @@ -472,7 +491,7 @@ def async_api_lock(hass, request, entity): @HANDLERS.register(('Alexa.LockController', 'Unlock')) @extract_entity @asyncio.coroutine -def async_api_unlock(hass, request, entity): +def async_api_unlock(hass, config, request, entity): """Process a unlock request.""" yield from hass.services.async_call(entity.domain, SERVICE_UNLOCK, { ATTR_ENTITY_ID: entity.entity_id @@ -484,7 +503,7 @@ def async_api_unlock(hass, request, entity): @HANDLERS.register(('Alexa.Speaker', 'SetVolume')) @extract_entity @asyncio.coroutine -def async_api_set_volume(hass, request, entity): +def async_api_set_volume(hass, config, request, entity): """Process a set volume request.""" volume = round(float(request[API_PAYLOAD]['volume'] / 100), 2) @@ -502,7 +521,7 @@ def async_api_set_volume(hass, request, entity): @HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) @extract_entity @asyncio.coroutine -def async_api_adjust_volume(hass, request, entity): +def async_api_adjust_volume(hass, config, request, entity): """Process a adjust volume request.""" volume_delta = int(request[API_PAYLOAD]['volume']) @@ -531,7 +550,7 @@ def async_api_adjust_volume(hass, request, entity): @HANDLERS.register(('Alexa.Speaker', 'SetMute')) @extract_entity @asyncio.coroutine -def async_api_set_mute(hass, request, entity): +def async_api_set_mute(hass, config, request, entity): """Process a set mute request.""" mute = bool(request[API_PAYLOAD]['mute']) @@ -550,7 +569,7 @@ def async_api_set_mute(hass, request, entity): @HANDLERS.register(('Alexa.PlaybackController', 'Play')) @extract_entity @asyncio.coroutine -def async_api_play(hass, request, entity): +def async_api_play(hass, config, request, entity): """Process a play request.""" data = { ATTR_ENTITY_ID: entity.entity_id @@ -565,7 +584,7 @@ def async_api_play(hass, request, entity): @HANDLERS.register(('Alexa.PlaybackController', 'Pause')) @extract_entity @asyncio.coroutine -def async_api_pause(hass, request, entity): +def async_api_pause(hass, config, request, entity): """Process a pause request.""" data = { ATTR_ENTITY_ID: entity.entity_id @@ -580,7 +599,7 @@ def async_api_pause(hass, request, entity): @HANDLERS.register(('Alexa.PlaybackController', 'Stop')) @extract_entity @asyncio.coroutine -def async_api_stop(hass, request, entity): +def async_api_stop(hass, config, request, entity): """Process a stop request.""" data = { ATTR_ENTITY_ID: entity.entity_id @@ -595,7 +614,7 @@ def async_api_stop(hass, request, entity): @HANDLERS.register(('Alexa.PlaybackController', 'Next')) @extract_entity @asyncio.coroutine -def async_api_next(hass, request, entity): +def async_api_next(hass, config, request, entity): """Process a next request.""" data = { ATTR_ENTITY_ID: entity.entity_id @@ -611,7 +630,7 @@ def async_api_next(hass, request, entity): @HANDLERS.register(('Alexa.PlaybackController', 'Previous')) @extract_entity @asyncio.coroutine -def async_api_previous(hass, request, entity): +def async_api_previous(hass, config, request, entity): """Process a previous request.""" data = { ATTR_ENTITY_ID: entity.entity_id diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2d01399bc07..e6da2de40f2 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -9,7 +9,9 @@ import voluptuous as vol from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE) +from homeassistant.helpers import entityfilter from homeassistant.util import dt as dt_util +from homeassistant.components.alexa import smart_home from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS @@ -18,6 +20,8 @@ REQUIREMENTS = ['warrant==0.5.0'] _LOGGER = logging.getLogger(__name__) +CONF_ALEXA = 'alexa' +CONF_ALEXA_FILTER = 'filter' CONF_COGNITO_CLIENT_ID = 'cognito_client_id' CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' @@ -26,6 +30,13 @@ MODE_DEV = 'development' DEFAULT_MODE = MODE_DEV DEPENDENCIES = ['http'] +ALEXA_SCHEMA = vol.Schema({ + vol.Optional( + CONF_ALEXA_FILTER, + default=lambda: entityfilter.generate_filter([], [], [], []) + ): entityfilter.FILTER_SCHEMA, +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): @@ -35,6 +46,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USER_POOL_ID): str, vol.Required(CONF_REGION): str, vol.Required(CONF_RELAYER): str, + vol.Optional(CONF_ALEXA): ALEXA_SCHEMA }), }, extra=vol.ALLOW_EXTRA) @@ -47,6 +59,10 @@ def async_setup(hass, config): else: kwargs = {CONF_MODE: DEFAULT_MODE} + if CONF_ALEXA not in kwargs: + kwargs[CONF_ALEXA] = ALEXA_SCHEMA({}) + + kwargs[CONF_ALEXA] = smart_home.Config(**kwargs[CONF_ALEXA]) cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) @asyncio.coroutine @@ -64,10 +80,11 @@ class Cloud: """Store the configuration of the cloud connection.""" def __init__(self, hass, mode, cognito_client_id=None, user_pool_id=None, - region=None, relayer=None): + region=None, relayer=None, alexa=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode + self.alexa_config = alexa self.id_token = None self.access_token = None self.refresh_token = None diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index c0b6bb96da1..91ad1cfc6ff 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -206,7 +206,9 @@ def async_handle_message(hass, cloud, handler_name, payload): @asyncio.coroutine def async_handle_alexa(hass, cloud, payload): """Handle an incoming IoT message for Alexa.""" - return (yield from smart_home.async_handle_message(hass, payload)) + return (yield from smart_home.async_handle_message(hass, + cloud.alexa_config, + payload)) @HANDLERS.register('cloud') diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py index fa1da879110..4427870c294 100644 --- a/homeassistant/components/mqtt_statestream.py +++ b/homeassistant/components/mqtt_statestream.py @@ -25,7 +25,17 @@ DEPENDENCIES = ['mqtt'] DOMAIN = 'mqtt_statestream' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: cv.FILTER_SCHEMA.extend({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), vol.Required(CONF_BASE_TOPIC): valid_publish_topic, vol.Optional(CONF_PUBLISH_ATTRIBUTES, default=False): cv.boolean, vol.Optional(CONF_PUBLISH_TIMESTAMPS, default=False): cv.boolean diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e5512b9140e..e5d0a34f76e 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -12,8 +12,7 @@ import voluptuous as vol from homeassistant.loader import get_platform from homeassistant.const import ( - CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, CONF_PLATFORM, - CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, + CONF_PLATFORM, CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, CONF_CONDITION, CONF_BELOW, CONF_ABOVE, CONF_TIMEOUT, SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC) @@ -563,16 +562,3 @@ SCRIPT_SCHEMA = vol.All( [vol.Any(SERVICE_SCHEMA, _SCRIPT_DELAY_SCHEMA, _SCRIPT_WAIT_TEMPLATE_SCHEMA, EVENT_SCHEMA, CONDITION_SCHEMA)], ) - -FILTER_SCHEMA = vol.Schema({ - vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ - vol.Optional(CONF_ENTITIES, default=[]): entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): - vol.All(ensure_list, [string]) - }), - vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ - vol.Optional(CONF_ENTITIES, default=[]): entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): - vol.All(ensure_list, [string]) - }) -}) diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d8d3f1c9325..f78c70e57d3 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,6 +1,30 @@ """Helper class to implement include/exclude of entities and domains.""" +import voluptuous as vol + from homeassistant.core import split_entity_id +from homeassistant.helpers import config_validation as cv + +CONF_INCLUDE_DOMAINS = 'include_domains' +CONF_INCLUDE_ENTITIES = 'include_entities' +CONF_EXCLUDE_DOMAINS = 'exclude_domains' +CONF_EXCLUDE_ENTITIES = 'exclude_entities' + +FILTER_SCHEMA = vol.All( + vol.Schema({ + vol.Optional(CONF_EXCLUDE_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_INCLUDE_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_INCLUDE_ENTITIES, default=[]): cv.entity_ids, + }), + lambda config: generate_filter( + config[CONF_INCLUDE_DOMAINS], + config[CONF_INCLUDE_ENTITIES], + config[CONF_EXCLUDE_DOMAINS], + config[CONF_EXCLUDE_ENTITIES], + )) def generate_filter(include_domains, include_entities, diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 3fe9145f2d6..55a412af1fd 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -5,9 +5,12 @@ from uuid import uuid4 import pytest from homeassistant.components.alexa import smart_home +from homeassistant.helpers import entityfilter from tests.common import async_mock_service +DEFAULT_CONFIG = smart_home.Config(filter=lambda entity_id: True) + def get_new_request(namespace, name, endpoint=None): """Generate a new API message.""" @@ -91,7 +94,7 @@ def test_wrong_version(hass): msg['directive']['header']['payloadVersion'] = '2' with pytest.raises(AssertionError): - yield from smart_home.async_handle_message(hass, msg) + yield from smart_home.async_handle_message(hass, DEFAULT_CONFIG, msg) @asyncio.coroutine @@ -157,7 +160,8 @@ def test_discovery_request(hass): 'position': 85 }) - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -319,6 +323,67 @@ def test_discovery_request(hass): raise AssertionError("Unknown appliance!") +@asyncio.coroutine +def test_exclude_filters(hass): + """Test exclusion filters.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + # setup test devices + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + + hass.states.async_set( + 'script.deny', 'off', {'friendly_name': "Blocked script"}) + + hass.states.async_set( + 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) + + config = smart_home.Config(filter=entityfilter.generate_filter( + include_domains=[], + include_entities=[], + exclude_domains=['script'], + exclude_entities=['cover.deny'], + )) + + msg = yield from smart_home.async_handle_message(hass, config, request) + + msg = msg['event'] + + assert len(msg['payload']['endpoints']) == 1 + + +@asyncio.coroutine +def test_include_filters(hass): + """Test inclusion filters.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + # setup test devices + hass.states.async_set( + 'switch.deny', 'on', {'friendly_name': "Blocked switch"}) + + hass.states.async_set( + 'script.deny', 'off', {'friendly_name': "Blocked script"}) + + hass.states.async_set( + 'automation.allow', 'off', {'friendly_name': "Allowed automation"}) + + hass.states.async_set( + 'group.allow', 'off', {'friendly_name': "Allowed group"}) + + config = smart_home.Config(filter=entityfilter.generate_filter( + include_domains=['automation', 'group'], + include_entities=['script.deny'], + exclude_domains=[], + exclude_entities=[], + )) + + msg = yield from smart_home.async_handle_message(hass, config, request) + + msg = msg['event'] + + assert len(msg['payload']['endpoints']) == 3 + + @asyncio.coroutine def test_api_entity_not_exists(hass): """Test api turn on process without entity.""" @@ -326,7 +391,8 @@ def test_api_entity_not_exists(hass): call_switch = async_mock_service(hass, 'switch', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -341,7 +407,8 @@ def test_api_entity_not_exists(hass): def test_api_function_not_implemented(hass): """Test api call that is not implemented to us.""" request = get_new_request('Alexa.HAHAAH', 'Sweet') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -366,9 +433,15 @@ def test_api_turn_on(hass, domain): 'friendly_name': "Test {}".format(domain) }) - call = async_mock_service(hass, 'homeassistant', 'turn_on') + call_domain = domain - msg = yield from smart_home.async_handle_message(hass, request) + if domain == 'group': + call_domain = 'homeassistant' + + call = async_mock_service(hass, call_domain, 'turn_on') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -393,9 +466,15 @@ def test_api_turn_off(hass, domain): 'friendly_name': "Test {}".format(domain) }) - call = async_mock_service(hass, 'homeassistant', 'turn_off') + call_domain = domain - msg = yield from smart_home.async_handle_message(hass, request) + if domain == 'group': + call_domain = 'homeassistant' + + call = async_mock_service(hass, call_domain, 'turn_off') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -420,7 +499,8 @@ def test_api_set_brightness(hass): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -450,7 +530,8 @@ def test_api_adjust_brightness(hass, result, adjust): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -483,7 +564,8 @@ def test_api_set_color_rgb(hass): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -516,7 +598,8 @@ def test_api_set_color_xy(hass): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -544,7 +627,8 @@ def test_api_set_color_temperature(hass): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -572,7 +656,8 @@ def test_api_decrease_color_temp(hass, result, initial): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -600,7 +685,8 @@ def test_api_increase_color_temp(hass, result, initial): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -626,7 +712,8 @@ def test_api_activate(hass, domain): call = async_mock_service(hass, domain, 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -651,7 +738,8 @@ def test_api_set_percentage_fan(hass): call_fan = async_mock_service(hass, 'fan', 'set_speed') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -679,7 +767,8 @@ def test_api_set_percentage_cover(hass): call_cover = async_mock_service(hass, 'cover', 'set_cover_position') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -709,7 +798,8 @@ def test_api_adjust_percentage_fan(hass, result, adjust): call_fan = async_mock_service(hass, 'fan', 'set_speed') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -740,7 +830,8 @@ def test_api_adjust_percentage_cover(hass, result, adjust): call_cover = async_mock_service(hass, 'cover', 'set_cover_position') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -766,7 +857,8 @@ def test_api_lock(hass, domain): call = async_mock_service(hass, domain, 'lock') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -791,7 +883,8 @@ def test_api_play(hass, domain): call = async_mock_service(hass, domain, 'media_play') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -816,7 +909,8 @@ def test_api_pause(hass, domain): call = async_mock_service(hass, domain, 'media_pause') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -841,7 +935,8 @@ def test_api_stop(hass, domain): call = async_mock_service(hass, domain, 'media_stop') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -866,7 +961,8 @@ def test_api_next(hass, domain): call = async_mock_service(hass, domain, 'media_next_track') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -891,7 +987,8 @@ def test_api_previous(hass, domain): call = async_mock_service(hass, domain, 'media_previous_track') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -918,7 +1015,8 @@ def test_api_set_volume(hass): call_media_player = async_mock_service(hass, 'media_player', 'volume_set') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -948,7 +1046,8 @@ def test_api_adjust_volume(hass, result, adjust): call_media_player = async_mock_service(hass, 'media_player', 'volume_set') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -976,7 +1075,8 @@ def test_api_mute(hass, domain): call = async_mock_service(hass, domain, 'volume_mute') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event']