From fbcf0880f3ef51f7686adb2245f62634e1e77837 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Nov 2018 19:18:20 +0100 Subject: [PATCH 001/238] Bump frontend to 20181103.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 108dcf74d9f..450daf5eb4c 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181103.0'] +REQUIREMENTS = ['home-assistant-frontend==20181103.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 22221a57e28..a6524fee2d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -476,7 +476,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181103.0 +home-assistant-frontend==20181103.1 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91219023f9c..75e21b33c38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.6.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181103.0 +home-assistant-frontend==20181103.1 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From f76ccb636cebd990eec747578e180688cea1e4ca Mon Sep 17 00:00:00 2001 From: rafale77 Date: Sat, 3 Nov 2018 14:25:05 -0700 Subject: [PATCH 002/238] Add support for various load level devices (#18161) --- homeassistant/components/zha/const.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 0b3e926fadc..88dee57aa70 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -22,7 +22,7 @@ def populate_data(): zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', zha.DeviceType.SMART_PLUG: 'switch', - + zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: 'light', zha.DeviceType.ON_OFF_LIGHT: 'light', zha.DeviceType.DIMMABLE_LIGHT: 'light', zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', @@ -47,6 +47,7 @@ def populate_data(): SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'switch', + zcl.clusters.general.LevelControl: 'light', zcl.clusters.measurement.RelativeHumidity: 'sensor', zcl.clusters.measurement.TemperatureMeasurement: 'sensor', zcl.clusters.measurement.PressureMeasurement: 'sensor', From 610b0b649466f199ab70d1c7b113bc31ef956c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 3 Nov 2018 23:47:31 +0100 Subject: [PATCH 003/238] Add Tautulli sensor platform (#17835) * Adds Tautulli as a sensor platform. * Remove blank last line. * Rewrite the platform to comply with review. * Linting issues. * Remove tailing newline. * Corrected typo * Correcte check_connection, removed wierd defaults, added line in imports, removed unused var, use the correct user list. * Use dict[key] for required config options. * Minor changes --- .coveragerc | 1 + homeassistant/components/sensor/tautulli.py | 148 ++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 152 insertions(+) create mode 100644 homeassistant/components/sensor/tautulli.py diff --git a/.coveragerc b/.coveragerc index b64699f685f..f5e9545625b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -789,6 +789,7 @@ omit = homeassistant/components/sensor/systemmonitor.py homeassistant/components/sensor/sytadin.py homeassistant/components/sensor/tank_utility.py + homeassistant/components/sensor/tautulli.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/thermoworks_smoke.py diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py new file mode 100644 index 00000000000..7b0d8e491d2 --- /dev/null +++ b/homeassistant/components/sensor/tautulli.py @@ -0,0 +1,148 @@ +""" +A platform which allows you to get information from Tautulli. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/sensor.tautulli/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PORT, + CONF_SSL, CONF_VERIFY_SSL) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['pytautulli==0.4.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_MONITORED_USERS = 'monitored_users' + +DEFAULT_NAME = 'Tautulli' +DEFAULT_PORT = '8181' +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_MONITORED_USERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Create the Tautulli sensor.""" + from pytautulli import Tautulli + + name = config.get(CONF_NAME) + host = config[CONF_HOST] + port = config.get(CONF_PORT) + api_key = config[CONF_API_KEY] + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + user = config.get(CONF_MONITORED_USERS) + use_ssl = config.get(CONF_SSL) + verify_ssl = config.get(CONF_VERIFY_SSL) + + session = async_get_clientsession(hass, verify_ssl) + tautulli = TautulliData(Tautulli( + host, port, api_key, hass.loop, session, use_ssl)) + + if not await tautulli.test_connection(): + raise PlatformNotReady + + sensor = [TautulliSensor(tautulli, name, monitored_conditions, user)] + + async_add_entities(sensor, True) + + +class TautulliSensor(Entity): + """Representation of a Tautulli sensor.""" + + def __init__(self, tautulli, name, monitored_conditions, users): + """Initialize the Tautulli sensor.""" + self.tautulli = tautulli + self.monitored_conditions = monitored_conditions + self.usernames = users + self.sessions = {} + self.home = {} + self._attributes = {} + self._name = name + self._state = None + + async def async_update(self): + """Get the latest data from the Tautulli API.""" + await self.tautulli.async_update() + self.home = self.tautulli.api.home_data + self.sessions = self.tautulli.api.session_data + self._attributes['Top Movie'] = self.home[0]['rows'][0]['title'] + self._attributes['Top TV Show'] = self.home[3]['rows'][0]['title'] + self._attributes['Top User'] = self.home[7]['rows'][0]['user'] + for key in self.sessions: + if 'sessions' not in key: + self._attributes[key] = self.sessions[key] + for user in self.tautulli.api.users: + if self.usernames is None or user in self.usernames: + userdata = self.tautulli.api.user_data + self._attributes[user] = {} + self._attributes[user]['Activity'] = userdata[user]['Activity'] + if self.monitored_conditions: + for key in self.monitored_conditions: + try: + self._attributes[user][key] = userdata[user][key] + except (KeyError, TypeError): + self._attributes[user][key] = '' + + @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.sessions['stream_count'] + + @property + def icon(self): + """Return the icon of the sensor.""" + return 'mdi:plex' + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + return self._attributes + + +class TautulliData: + """Get the latest data and update the states.""" + + def __init__(self, api): + """Initialize the data object.""" + self.api = api + + @Throttle(TIME_BETWEEN_UPDATES) + async def async_update(self): + """Get the latest data from Tautulli.""" + await self.api.get_data() + + async def test_connection(self): + """Test connection to Tautulli.""" + await self.api.test_connection() + connection_status = self.api.connection + return connection_status diff --git a/requirements_all.txt b/requirements_all.txt index a6524fee2d7..4dbcc6b2205 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1115,6 +1115,9 @@ pystride==0.1.7 # homeassistant.components.sensor.syncthru pysyncthru==0.3.1 +# homeassistant.components.sensor.tautulli +pytautulli==0.4.0 + # homeassistant.components.media_player.liveboxplaytv pyteleloisirs==3.4 From 155df912e51d10ddeb458d80d1e5b5b9eddcc812 Mon Sep 17 00:00:00 2001 From: Corey Edwards Date: Sat, 3 Nov 2018 16:48:08 -0600 Subject: [PATCH 004/238] Add option to manually specify device detection method (#17852) * Add option to manually specify device detection method * Fix style and lint issue --- .../components/device_tracker/mikrotik.py | 96 ++++++++----------- 1 file changed, 41 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 320468159e0..a4a15fb9d60 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -12,19 +12,20 @@ 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, CONF_PORT) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_METHOD) REQUIREMENTS = ['librouteros==2.1.1'] -MTK_DEFAULT_API_PORT = '8728' - _LOGGER = logging.getLogger(__name__) +MTK_DEFAULT_API_PORT = '8728' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=MTK_DEFAULT_API_PORT): cv.port + vol.Optional(CONF_METHOD): cv.string, + vol.Optional(CONF_PORT, default=MTK_DEFAULT_API_PORT): cv.port, }) @@ -45,6 +46,7 @@ class MikrotikScanner(DeviceScanner): self.port = config[CONF_PORT] self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] + self.method = config[CONF_METHOD] self.connected = False self.success_init = False @@ -53,28 +55,18 @@ class MikrotikScanner(DeviceScanner): self.success_init = self.connect_to_device() if self.success_init: - _LOGGER.info( - "Start polling Mikrotik (%s) router...", - self.host - ) + _LOGGER.info("Start polling Mikrotik (%s) router...", self.host) self._update_info() else: - _LOGGER.error( - "Connection to Mikrotik (%s) failed", - self.host - ) + _LOGGER.error("Connection to Mikrotik (%s) failed", self.host) def connect_to_device(self): """Connect to Mikrotik method.""" import librouteros try: self.client = librouteros.connect( - self.host, - self.username, - self.password, - port=int(self.port), - encoding='utf-8' - ) + self.host, self.username, self.password, port=int(self.port), + encoding='utf-8') try: routerboard_info = self.client( @@ -86,16 +78,15 @@ class MikrotikScanner(DeviceScanner): raise if routerboard_info: - _LOGGER.info("Connected to Mikrotik %s with IP %s", - routerboard_info[0].get('model', 'Router'), - self.host) + _LOGGER.info( + "Connected to Mikrotik %s with IP %s", + routerboard_info[0].get('model', 'Router'), self.host) self.connected = True try: self.capsman_exist = self.client( - cmd='/caps-man/interface/getall' - ) + cmd='/caps-man/interface/getall') except (librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError): @@ -103,27 +94,27 @@ class MikrotikScanner(DeviceScanner): if not self.capsman_exist: _LOGGER.info( - 'Mikrotik %s: Not a CAPSman controller. Trying ' - 'local interfaces ', - self.host - ) + "Mikrotik %s: Not a CAPSman controller. Trying " + "local interfaces", self.host) try: self.wireless_exist = self.client( - cmd='/interface/wireless/getall' - ) + cmd='/interface/wireless/getall') except (librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError): self.wireless_exist = False - if not self.wireless_exist: + if not self.wireless_exist or self.method == 'ip': _LOGGER.info( - 'Mikrotik %s: Wireless adapters not found. Try to ' - 'use DHCP lease table as presence tracker source. ' - 'Please decrease lease time as much as possible.', - self.host - ) + "Mikrotik %s: Wireless adapters not found. Try to " + "use DHCP lease table as presence tracker source. " + "Please decrease lease time as much as possible", + self.host) + if self.method: + _LOGGER.info( + "Mikrotik %s: Manually selected polling method %s", + self.host, self.method) except (librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, @@ -143,28 +134,27 @@ class MikrotikScanner(DeviceScanner): def _update_info(self): """Retrieve latest information from the Mikrotik box.""" - if self.capsman_exist: - devices_tracker = 'capsman' - elif self.wireless_exist: - devices_tracker = 'wireless' + if self.method: + devices_tracker = self.method else: - devices_tracker = 'ip' + if self.capsman_exist: + devices_tracker = 'capsman' + elif self.wireless_exist: + devices_tracker = 'wireless' + else: + devices_tracker = 'ip' _LOGGER.info( "Loading %s devices from Mikrotik (%s) ...", - devices_tracker, - self.host - ) + devices_tracker, self.host) device_names = self.client(cmd='/ip/dhcp-server/lease/getall') if devices_tracker == 'capsman': devices = self.client( - cmd='/caps-man/registration-table/getall' - ) + cmd='/caps-man/registration-table/getall') elif devices_tracker == 'wireless': devices = self.client( - cmd='/interface/wireless/registration-table/getall' - ) + cmd='/interface/wireless/registration-table/getall') else: devices = device_names @@ -172,21 +162,17 @@ class MikrotikScanner(DeviceScanner): return False mac_names = {device.get('mac-address'): device.get('host-name') - for device in device_names - if device.get('mac-address')} + for device in device_names if device.get('mac-address')} - if self.wireless_exist or self.capsman_exist: + if devices_tracker in ('wireless', 'capsman'): self.last_results = { device.get('mac-address'): mac_names.get(device.get('mac-address')) - for device in devices - } + for device in devices} else: self.last_results = { device.get('mac-address'): mac_names.get(device.get('mac-address')) - for device in device_names - if device.get('active-address') - } + for device in device_names if device.get('active-address')} return True From 5dd691e55d8a4e67ccd554d1d9636e754aa656a0 Mon Sep 17 00:00:00 2001 From: Jorim Tielemans Date: Sun, 4 Nov 2018 01:33:05 +0100 Subject: [PATCH 005/238] Rename huawei_lte.py to test_huawei_lte.py (#18170) --- tests/components/{huawei_lte.py => test_huawei_lte.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/components/{huawei_lte.py => test_huawei_lte.py} (100%) diff --git a/tests/components/huawei_lte.py b/tests/components/test_huawei_lte.py similarity index 100% rename from tests/components/huawei_lte.py rename to tests/components/test_huawei_lte.py From 164c68093b94a9c28127622c7dc866a885829024 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 4 Nov 2018 09:15:57 +0100 Subject: [PATCH 006/238] Improve netgear_lte logging when unconnected (#18163) * Improve netgear_lte logging when unconnected * Use callback --- homeassistant/components/netgear_lte.py | 65 +++++++++++++++++++------ 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/netgear_lte.py b/homeassistant/components/netgear_lte.py index e5e9a0fc2e9..7658015ea67 100644 --- a/homeassistant/components/netgear_lte.py +++ b/homeassistant/components/netgear_lte.py @@ -14,6 +14,7 @@ import aiohttp from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.util import Throttle @@ -39,11 +40,13 @@ CONFIG_SCHEMA = vol.Schema({ class ModemData: """Class for modem state.""" + host = attr.ib() modem = attr.ib() serial_number = attr.ib(init=False, default=None) unread_count = attr.ib(init=False, default=None) usage = attr.ib(init=False, default=None) + connected = attr.ib(init=False, default=True) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): @@ -54,7 +57,13 @@ class ModemData: self.serial_number = information.serial_number self.unread_count = sum(1 for x in information.sms if x.unread) self.usage = information.usage + if not self.connected: + _LOGGER.warning("Connected to %s", self.host) + self.connected = True except eternalegypt.Error: + if self.connected: + _LOGGER.warning("Lost connection to %s", self.host) + self.connected = False self.unread_count = None self.usage = None @@ -90,34 +99,60 @@ async def async_setup(hass, config): return True -async def _setup_lte(hass, lte_config, delay=0): +async def _setup_lte(hass, lte_config): """Set up a Netgear LTE modem.""" import eternalegypt - if delay: - await asyncio.sleep(delay) - host = lte_config[CONF_HOST] password = lte_config[CONF_PASSWORD] websession = hass.data[DATA_KEY].websession - modem = eternalegypt.Modem(hostname=host, websession=websession) - try: - await modem.login(password=password) - except eternalegypt.Error: - delay = max(15, min(2*delay, 300)) - _LOGGER.warning("Retrying %s in %d seconds", host, delay) - hass.loop.create_task(_setup_lte(hass, lte_config, delay)) - return + modem_data = ModemData(host, modem) - modem_data = ModemData(modem) + try: + await _login(hass, modem_data, password) + except eternalegypt.Error: + retry_task = hass.loop.create_task( + _retry_login(hass, modem_data, password)) + + @callback + def cleanup_retry(event): + """Clean up retry task resources.""" + if not retry_task.done(): + retry_task.cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) + + +async def _login(hass, modem_data, password): + """Log in and complete setup.""" + await modem_data.modem.login(password=password) await modem_data.async_update() - hass.data[DATA_KEY].modem_data[host] = modem_data + hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data async def cleanup(event): """Clean up resources.""" - await modem.logout() + await modem_data.modem.logout() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + + +async def _retry_login(hass, modem_data, password): + """Sleep and retry setup.""" + import eternalegypt + + _LOGGER.warning( + "Could not connect to %s. Will keep trying.", modem_data.host) + + modem_data.connected = False + delay = 15 + + while not modem_data.connected: + await asyncio.sleep(delay) + + try: + await _login(hass, modem_data, password) + except eternalegypt.Error: + delay = min(2*delay, 300) From 5418e0510d898da100ec4b65e6b6e99781760317 Mon Sep 17 00:00:00 2001 From: Florian Klien Date: Sun, 4 Nov 2018 09:17:05 +0100 Subject: [PATCH 007/238] XMPP HTTP upload (#17426) * notify.xmpp: first working http upload * extension guessing for upload * docstrings, flake8, pylint * hass.async_add_executor_job(...) * catch more errors, allow unverified SSL request allow user to specify unverified SSL request to URL cleaner code catch more exceptions * pylint * catching XMPP exceptions, timeout for requests call removed calls for roster and presence added timeout for upload request call cleared up debug, info, warning messages cleared up requests call for secure and insecure retrieval of image catching IqError, IqTimeout, XMPPError from slixmpp docstring updated * added timout for http upload of local files * timeout, mimetypes, random filenames guessing filetypes and mimetypes with stdlib mimetypes setting a random filename for privacy working around slixmpp timeout issues with asyncio.wait_for * code cleanup * added file upload for rooms/groupchats * version bump for slixmpp to 1.4.1 added NotConnectedError, removed double catches of IqErrors removed asyncio import * slixmpp 1.4.1 in requirements_all * added url and path templating * Minor changes * fixed review requests fixed possible path issue for foo/../bar/ paths fixed possible access for non-whitelisted files fixed None or X fixed try-else block, moved else block into try fixed raising error in upload_file if url is None fixed using data.get after it's already been checked fixed added docstring for tiny get_url function --- homeassistant/components/notify/xmpp.py | 269 +++++++++++++++++++++--- requirements_all.txt | 2 +- 2 files changed, 245 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 1f4417e07b5..eac20c62797 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -4,31 +4,50 @@ Jabber (XMPP) notification service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.xmpp/ """ +from concurrent.futures import TimeoutError as FutTimeoutError import logging +import mimetypes +import pathlib +import random +import string +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_PASSWORD, CONF_SENDER, CONF_RECIPIENT, CONF_ROOM, CONF_RESOURCE) + CONF_PASSWORD, CONF_RECIPIENT, CONF_RESOURCE, CONF_ROOM, CONF_SENDER) +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.template as template_helper -REQUIREMENTS = ['slixmpp==1.4.0'] +REQUIREMENTS = ['slixmpp==1.4.1'] _LOGGER = logging.getLogger(__name__) +ATTR_DATA = 'data' +ATTR_PATH = 'path' +ATTR_PATH_TEMPLATE = 'path_template' +ATTR_TIMEOUT = 'timeout' +ATTR_URL = 'url' +ATTR_URL_TEMPLATE = 'url_template' +ATTR_VERIFY = 'verify' + CONF_TLS = 'tls' CONF_VERIFY = 'verify' +DEFAULT_CONTENT_TYPE = 'application/octet-stream' +DEFAULT_RESOURCE = 'home-assistant' +XEP_0363_TIMEOUT = 10 + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_RECIPIENT): cv.string, + vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, + vol.Optional(CONF_ROOM, default=''): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, - vol.Optional(CONF_ROOM, default=''): cv.string, - vol.Optional(CONF_RESOURCE, default="home-assistant"): cv.string, }) @@ -38,16 +57,16 @@ async def async_get_service(hass, config, discovery_info=None): config.get(CONF_SENDER), config.get(CONF_RESOURCE), config.get(CONF_PASSWORD), config.get(CONF_RECIPIENT), config.get(CONF_TLS), config.get(CONF_VERIFY), - config.get(CONF_ROOM), hass.loop) + config.get(CONF_ROOM), hass) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" def __init__(self, sender, resource, password, - recipient, tls, verify, room, loop): + recipient, tls, verify, room, hass): """Initialize the service.""" - self._loop = loop + self._hass = hass self._sender = sender self._resource = resource self._password = password @@ -59,18 +78,26 @@ class XmppNotificationService(BaseNotificationService): async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - data = '{}: {}'.format(title, message) if title else message + text = '{}: {}'.format(title, message) if title else message + data = kwargs.get(ATTR_DATA) + timeout = data.get(ATTR_TIMEOUT, XEP_0363_TIMEOUT) if data else None await async_send_message( '{}/{}'.format(self._sender, self._resource), self._password, self._recipient, self._tls, - self._verify, self._room, self._loop, data) + self._verify, self._room, self._hass, text, + timeout, data) -async def async_send_message(sender, password, recipient, use_tls, - verify_certificate, room, loop, message): +async def async_send_message( + sender, password, recipient, use_tls, verify_certificate, room, hass, + message, timeout=None, data=None): """Send a message over XMPP.""" import slixmpp + from slixmpp.exceptions import IqError, IqTimeout, XMPPError + from slixmpp.xmlstream.xmlstream import NotConnectedError + from slixmpp.plugins.xep_0363.http_upload import FileTooBig, \ + FileUploadError, UploadServiceNotFound class SendNotificationBot(slixmpp.ClientXMPP): """Service for sending Jabber (XMPP) messages.""" @@ -79,8 +106,7 @@ async def async_send_message(sender, password, recipient, use_tls, """Initialize the Jabber Bot.""" super().__init__(sender, password) - # need hass.loop!! - self.loop = loop + self.loop = hass.loop self.force_starttls = use_tls self.use_ipv6 = False @@ -93,29 +119,222 @@ async def async_send_message(sender, password, recipient, use_tls, if not verify_certificate: self.add_event_handler('ssl_invalid_cert', self.discard_ssl_invalid_cert) + if data: + # Init XEPs for image sending + self.register_plugin('xep_0030') # OOB dep + self.register_plugin('xep_0066') # Out of Band Data + self.register_plugin('xep_0071') # XHTML IM + self.register_plugin('xep_0128') # Service Discovery + self.register_plugin('xep_0363') # HTTP upload self.connect(force_starttls=self.force_starttls, use_ssl=False) - def start(self, event): + async def start(self, event): """Start the communication and sends the message.""" - self.get_roster() - self.send_presence() - if room: - _LOGGER.debug("Joining room %s", room) - self.plugin['xep_0045'].join_muc(room, sender, wait=True) - self.send_message(mto=room, mbody=message, mtype='groupchat') - else: - self.send_message(mto=recipient, mbody=message, mtype='chat') + # Sending image and message independently from each other + if data: + await self.send_file(timeout=timeout) + if message: + self.send_text_message() + self.disconnect(wait=True) + async def send_file(self, timeout=None): + """Send file via XMPP. + + Send XMPP file message using OOB (XEP_0066) and + HTTP Upload (XEP_0363) + """ + if room: + self.plugin['xep_0045'].join_muc(room, sender, wait=True) + + try: + # Uploading with XEP_0363 + _LOGGER.debug("Timeout set to %ss", timeout) + url = await self.upload_file(timeout=timeout) + + _LOGGER.info("Upload success") + if room: + _LOGGER.info("Sending file to %s", room) + message = self.Message(sto=room, stype='groupchat') + else: + _LOGGER.info("Sending file to %s", recipient) + message = self.Message(sto=recipient, stype='chat') + + message['body'] = url + # pylint: disable=invalid-sequence-index + message['oob']['url'] = url + try: + message.send() + except (IqError, IqTimeout, XMPPError) as ex: + _LOGGER.error("Could not send image message %s", ex) + except (IqError, IqTimeout, XMPPError) as ex: + _LOGGER.error("Upload error, could not send message %s", ex) + except NotConnectedError as ex: + _LOGGER.error("Connection error %s", ex) + except FileTooBig as ex: + _LOGGER.error( + "File too big for server, could not upload file %s", ex) + except UploadServiceNotFound as ex: + _LOGGER.error("UploadServiceNotFound: " + " could not upload file %s", ex) + except FileUploadError as ex: + _LOGGER.error("FileUploadError, could not upload file %s", ex) + except requests.exceptions.SSLError as ex: + _LOGGER.error("Cannot establish SSL connection %s", ex) + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Cannot connect to server %s", ex) + except (FileNotFoundError, + PermissionError, + IsADirectoryError, + TimeoutError) as ex: + _LOGGER.error("Error reading file %s", ex) + except FutTimeoutError as ex: + _LOGGER.error("The server did not respond in time, %s", ex) + + async def upload_file(self, timeout=None): + """Upload file to Jabber server and return new URL. + + upload a file with Jabber XEP_0363 from a remote URL or a local + file path and return a URL of that file. + """ + if data.get(ATTR_URL_TEMPLATE): + _LOGGER.debug( + "Got url template: %s", data[ATTR_URL_TEMPLATE]) + templ = template_helper.Template( + data[ATTR_URL_TEMPLATE], hass) + get_url = template_helper.render_complex(templ, None) + url = await self.upload_file_from_url( + get_url, timeout=timeout) + elif data.get(ATTR_URL): + url = await self.upload_file_from_url( + data[ATTR_URL], timeout=timeout) + elif data.get(ATTR_PATH_TEMPLATE): + _LOGGER.debug( + "Got path template: %s", data[ATTR_PATH_TEMPLATE]) + templ = template_helper.Template( + data[ATTR_PATH_TEMPLATE], hass) + get_path = template_helper.render_complex(templ, None) + url = await self.upload_file_from_path( + get_path, timeout=timeout) + elif data.get(ATTR_PATH): + url = await self.upload_file_from_path( + data[ATTR_PATH], timeout=timeout) + else: + url = None + + if url is None: + _LOGGER.error("No path or URL found for file") + raise FileUploadError("Could not upload file") + + return url + + async def upload_file_from_url(self, url, timeout=None): + """Upload a file from a URL. Returns a URL. + + uploaded via XEP_0363 and HTTP and returns the resulting URL + """ + _LOGGER.info("Getting file from %s", url) + + def get_url(url): + """Return result for GET request to url.""" + return requests.get( + url, verify=data.get(ATTR_VERIFY, True), timeout=timeout) + result = await hass.async_add_executor_job(get_url, url) + + if result.status_code >= 400: + _LOGGER.error("Could not load file from %s", url) + return None + + filesize = len(result.content) + + # we need a file extension, the upload server needs a + # filename, if none is provided, through the path we guess + # the extension + # also setting random filename for privacy + if data.get(ATTR_PATH): + # using given path as base for new filename. Don't guess type + filename = self.get_random_filename(data.get(ATTR_PATH)) + else: + extension = mimetypes.guess_extension( + result.headers['Content-Type']) or ".unknown" + _LOGGER.debug("Got %s extension", extension) + filename = self.get_random_filename(None, extension=extension) + + _LOGGER.info("Uploading file from URL, %s", filename) + + url = await self['xep_0363'].upload_file( + filename, size=filesize, input_file=result.content, + content_type=result.headers['Content-Type'], timeout=timeout) + + return url + + async def upload_file_from_path(self, path, timeout=None): + """Upload a file from a local file path via XEP_0363.""" + _LOGGER.info('Uploading file from path, %s ...', path) + + if not hass.config.is_allowed_path(path): + raise PermissionError( + "Could not access file. Not in whitelist.") + + with open(path, 'rb') as upfile: + _LOGGER.debug("Reading file %s", path) + input_file = upfile.read() + filesize = len(input_file) + _LOGGER.debug("Filesize is %s bytes", filesize) + + content_type = mimetypes.guess_type(path)[0] + if content_type is None: + content_type = DEFAULT_CONTENT_TYPE + _LOGGER.debug("Content type is %s", content_type) + + # set random filename for privacy + filename = self.get_random_filename(data.get(ATTR_PATH)) + _LOGGER.debug("Uploading file with random filename %s", filename) + + url = await self['xep_0363'].upload_file( + filename, size=filesize, input_file=input_file, + content_type=content_type, timeout=timeout) + + return url + + def send_text_message(self): + """Send a text only message to a room or a recipient.""" + try: + if room: + _LOGGER.debug("Joining room %s", room) + self.plugin['xep_0045'].join_muc(room, sender, wait=True) + self.send_message( + mto=room, mbody=message, mtype='groupchat') + else: + _LOGGER.debug("Sending message to %s", recipient) + self.send_message( + mto=recipient, mbody=message, mtype='chat') + except (IqError, IqTimeout, XMPPError) as ex: + _LOGGER.error("Could not send text message %s", ex) + except NotConnectedError as ex: + _LOGGER.error("Connection error %s", ex) + + # pylint: disable=no-self-use + def get_random_filename(self, filename, extension=None): + """Return a random filename, leaving the extension intact.""" + if extension is None: + path = pathlib.Path(filename) + if path.suffix: + extension = ''.join(path.suffixes) + else: + extension = ".txt" + return ''.join(random.choice(string.ascii_letters) + for i in range(10)) + extension + def disconnect_on_login_fail(self, event): """Disconnect from the server if credentials are invalid.""" - _LOGGER.warning('Login failed') + _LOGGER.warning("Login failed") self.disconnect() @staticmethod def discard_ssl_invalid_cert(event): """Do nothing if ssl certificate is invalid.""" - _LOGGER.info('Ignoring invalid ssl certificate as requested') + _LOGGER.info("Ignoring invalid SSL certificate as requested") SendNotificationBot() diff --git a/requirements_all.txt b/requirements_all.txt index 4dbcc6b2205..bc115579745 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1385,7 +1385,7 @@ slacker==0.9.65 sleepyq==0.6 # homeassistant.components.notify.xmpp -slixmpp==1.4.0 +slixmpp==1.4.1 # homeassistant.components.smappee smappy==0.2.16 From 42cb23f7686e537e83b2539d92989c2198088162 Mon Sep 17 00:00:00 2001 From: Jorim Tielemans Date: Sun, 4 Nov 2018 09:50:00 +0100 Subject: [PATCH 008/238] Update Coinbase icons (#18172) * Add extra icons and don't rely on the name * Use dictionary for icons use safe get() with default value * Use better vars --- homeassistant/components/sensor/coinbase.py | 25 +++++++++------------ 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/coinbase.py b/homeassistant/components/sensor/coinbase.py index 40444dee93c..d25b7b786f8 100644 --- a/homeassistant/components/sensor/coinbase.py +++ b/homeassistant/components/sensor/coinbase.py @@ -9,17 +9,20 @@ from homeassistant.helpers.entity import Entity ATTR_NATIVE_BALANCE = "Balance in native currency" -BTC_ICON = 'mdi:currency-btc' - -COIN_ICON = 'mdi:coin' +CURRENCY_ICONS = { + 'BTC': 'mdi:currency-btc', + 'ETH': 'mdi:currency-eth', + 'EUR': 'mdi:currency-eur', + 'LTC': 'mdi:litecoin', + 'USD': 'mdi:currency-usd' +} +DEFAULT_COIN_ICON = 'mdi:coin' CONF_ATTRIBUTION = "Data provided by coinbase.com" DATA_COINBASE = 'coinbase_cache' DEPENDENCIES = ['coinbase'] -ETH_ICON = 'mdi:currency-eth' - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Coinbase sensors.""" @@ -68,11 +71,7 @@ class AccountSensor(Entity): @property def icon(self): """Return the icon to use in the frontend, if any.""" - if self._name == "Coinbase BTC Wallet": - return BTC_ICON - if self._name == "Coinbase ETH Wallet": - return ETH_ICON - return COIN_ICON + return CURRENCY_ICONS.get(self._unit_of_measurement, DEFAULT_COIN_ICON) @property def device_state_attributes(self): @@ -122,11 +121,7 @@ class ExchangeRateSensor(Entity): @property def icon(self): """Return the icon to use in the frontend, if any.""" - if self._name == "BTC Exchange Rate": - return BTC_ICON - if self._name == "ETH Exchange Rate": - return ETH_ICON - return COIN_ICON + return CURRENCY_ICONS.get(self.currency, DEFAULT_COIN_ICON) @property def device_state_attributes(self): From eb0d989c88f23a802209bdbd5cfc3e111067b79c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 4 Nov 2018 12:19:04 +0100 Subject: [PATCH 009/238] Bugfix discovery (delete/mqtt) call for Hass.io (#18159) * Bugfix discovery delete call for Hass.io * Fix host * fix tests --- homeassistant/components/hassio/discovery.py | 2 +- homeassistant/components/mqtt/config_flow.py | 6 +++--- tests/components/mqtt/test_config_flow.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 4c7c5a6597f..a5f62b9e1a1 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -71,7 +71,7 @@ class HassIODiscovery(HomeAssistantView): async def delete(self, request, uuid): """Handle remove discovery requests.""" - data = request.json() + data = await request.json() await self.async_process_del(data) return web.Response() diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index e0d1e692c60..aee825d06de 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( - CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_PROTOCOL) + CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_PROTOCOL, CONF_HOST) from .const import CONF_BROKER, CONF_DISCOVERY, DEFAULT_DISCOVERY @@ -80,7 +80,7 @@ class FlowHandler(config_entries.ConfigFlow): data = self._hassio_discovery can_connect = await self.hass.async_add_executor_job( try_connection, - data[CONF_BROKER], + data[CONF_HOST], data[CONF_PORT], data.get(CONF_USERNAME), data.get(CONF_PASSWORD), @@ -90,7 +90,7 @@ class FlowHandler(config_entries.ConfigFlow): if can_connect: return self.async_create_entry( title=data['addon'], data={ - CONF_BROKER: data[CONF_BROKER], + CONF_BROKER: data[CONF_HOST], CONF_PORT: data[CONF_PORT], CONF_USERNAME: data.get(CONF_USERNAME), CONF_PASSWORD: data.get(CONF_PASSWORD), diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 08bb4e54a39..66bf9b97807 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -119,7 +119,7 @@ async def test_hassio_confirm(hass, mock_try_connection, 'mqtt', data={ 'addon': 'Mock Addon', - 'broker': 'mock-broker', + 'host': 'mock-broker', 'port': 1883, 'username': 'mock-user', 'password': 'mock-pass', From 27e159f63fac4e6be2eb48c0e39889cec91a0036 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 4 Nov 2018 15:15:14 +0100 Subject: [PATCH 010/238] Handle TensorFlow like OpenCV (#18185) * Handle TensorFlow like OpenCV * Update requirements_all.txt --- homeassistant/components/image_processing/tensorflow.py | 3 +-- requirements_all.txt | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index f333aa1767c..a2cd997bb76 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -20,8 +20,7 @@ from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.3', 'pillow==5.2.0', - 'protobuf==3.6.1', 'tensorflow==1.11.0'] +REQUIREMENTS = ['numpy==1.15.3', 'pillow==5.2.0', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index bc115579745..cb687de6551 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1472,9 +1472,6 @@ temescal==0.1 # homeassistant.components.sensor.temper temperusb==1.5.3 -# homeassistant.components.image_processing.tensorflow -tensorflow==1.11.0 - # homeassistant.components.tesla teslajsonpy==0.0.23 From a498e1591073069a252762b7e5b9acdc006d2692 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 4 Nov 2018 15:19:48 +0100 Subject: [PATCH 011/238] Add support for TensorFlow in official docker (#18191) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 92b85c29325..4cd4f3e8871 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ COPY requirements_all.txt requirements_all.txt # Uninstall enum34 because some dependencies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython + pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython tensorflow # Copy source COPY . . From 0992e83f8d3c8bcdaefb4b497331b8302a3253c1 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sun, 4 Nov 2018 08:20:32 -0600 Subject: [PATCH 012/238] Remove config (breaking change) (#18153) --- homeassistant/components/ffmpeg.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 791f6d29175..a2f0ca19231 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -37,14 +37,12 @@ CONF_INPUT = 'input' CONF_FFMPEG_BIN = 'ffmpeg_bin' CONF_EXTRA_ARGUMENTS = 'extra_arguments' CONF_OUTPUT = 'output' -CONF_RUN_TEST = 'run_test' DEFAULT_BINARY = 'ffmpeg' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_FFMPEG_BIN, default=DEFAULT_BINARY): cv.string, - vol.Optional(CONF_RUN_TEST): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) From dbf721cd2c535d235e4a3fb01976fe06428994d0 Mon Sep 17 00:00:00 2001 From: Troy Kelly Date: Mon, 5 Nov 2018 00:46:02 +1000 Subject: [PATCH 013/238] Added AU (Australia) (#18183) Added missing Australia region --- homeassistant/components/sensor/waze_travel_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index e4cc8381ede..090aff78064 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -37,7 +37,7 @@ DEFAULT_REALTIME = True ICON = 'mdi:car' -REGIONS = ['US', 'NA', 'EU', 'IL'] +REGIONS = ['US', 'NA', 'EU', 'IL', 'AU'] SCAN_INTERVAL = timedelta(minutes=5) From e161dc3b77d7a8bd091f8476b46ba543b41dfd65 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Nov 2018 17:22:03 +0100 Subject: [PATCH 014/238] Upgrade toonlib to 1.1.3 (#18189) --- homeassistant/components/toon.py | 10 +++++----- requirements_all.txt | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/toon.py b/homeassistant/components/toon.py index cfd0d297d54..01f170f0b31 100644 --- a/homeassistant/components/toon.py +++ b/homeassistant/components/toon.py @@ -4,17 +4,17 @@ Toon van Eneco Support. For more details about this component, please refer to the documentation at https://home-assistant.io/components/toon/ """ -import logging from datetime import datetime, timedelta +import logging import voluptuous as vol +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = ['toonlib==1.0.2'] +REQUIREMENTS = ['toonlib==1.1.3'] _LOGGER = logging.getLogger(__name__) @@ -62,8 +62,8 @@ def setup(hass, config): class ToonDataStore: """An object to store the Toon data.""" - def __init__(self, username, password, gas=DEFAULT_GAS, - solar=DEFAULT_SOLAR): + def __init__( + self, username, password, gas=DEFAULT_GAS, solar=DEFAULT_SOLAR): """Initialize Toon.""" from toonlib import Toon diff --git a/requirements_all.txt b/requirements_all.txt index cb687de6551..94a2adf274c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1488,7 +1488,7 @@ tikteck==0.4 todoist-python==7.0.17 # homeassistant.components.toon -toonlib==1.0.2 +toonlib==1.1.3 # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.20 From 44556a86e3022b9ecdff028e80917fbda0b78852 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sun, 4 Nov 2018 19:09:14 +0200 Subject: [PATCH 015/238] SMA: Optional import in schema & backoff fix (#18099) --- homeassistant/components/sensor/sma.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/sma.py b/homeassistant/components/sensor/sma.py index 4b0c33191dc..3f17b4971ec 100644 --- a/homeassistant/components/sensor/sma.py +++ b/homeassistant/components/sensor/sma.py @@ -35,7 +35,10 @@ GROUPS = ['user', 'installer'] def _check_sensor_schema(conf): """Check sensors and attributes are valid.""" - import pysma + try: + import pysma + except ImportError: + return conf valid = list(conf[CONF_CUSTOM].keys()) valid.extend([s.name for s in pysma.SENSORS]) @@ -73,6 +76,9 @@ async def async_setup_platform( """Set up SMA WebConnect sensor.""" import pysma + # Check config again during load - dependency available + config = _check_sensor_schema(config) + # Sensor_defs from the custom config for name, prop in config[CONF_CUSTOM].items(): n_s = pysma.Sensor(name, prop['key'], prop['unit'], prop['factor']) @@ -107,18 +113,24 @@ async def async_setup_platform( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_close_session) backoff = 0 + backoff_step = 0 async def async_sma(event): """Update all the SMA sensors.""" - nonlocal backoff + nonlocal backoff, backoff_step if backoff > 1: backoff -= 1 return values = await sma.read(used_sensors) - if values is None: - backoff = 10 + if not values: + try: + backoff = [1, 1, 1, 6, 30][backoff_step] + backoff_step += 1 + except IndexError: + backoff = 60 return + backoff_step = 0 tasks = [] for sensor in hass_sensors: From 4a7507bceae9be159b12da7140e1d9d4d0a723c9 Mon Sep 17 00:00:00 2001 From: rafale77 Date: Sun, 4 Nov 2018 11:21:53 -0800 Subject: [PATCH 016/238] Update python-openzwave to 0.4.11 (#18160) * Update Python OpenZWave to 0.4.11 * Update requirements_all.txt --- homeassistant/components/zwave/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index a4f8dcd1b3f..74c5cdb52a9 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -42,7 +42,7 @@ from .discovery_schemas import DISCOVERY_SCHEMAS from .util import (check_node_schema, check_value_schema, node_name, check_has_unique_id, is_node_parsed) -REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.10'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.11'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 94a2adf274c..a11969c4081 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1231,7 +1231,7 @@ python-wink==1.10.1 python_opendata_transport==0.1.4 # homeassistant.components.zwave -python_openzwave==0.4.10 +python_openzwave==0.4.11 # homeassistant.components.egardia pythonegardia==1.0.39 From 9a6c229b1da20e697f1f40170ed0a424e9617c7b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 4 Nov 2018 21:08:27 +0100 Subject: [PATCH 017/238] Refactor mysensors message handling (#17214) * Refactor mysensors message handling * Add handler module and register handlers per message type or message sub-type. This will allow easier extension of message handling in the future. * Move some common functions to a helpers module. * Add node handler and signal * Fix inconsistent return * Upgrade pymysensors to 0.18.0 * Fix bug in message modification. --- .../components/device_tracker/mysensors.py | 9 +- .../components/mysensors/__init__.py | 4 +- homeassistant/components/mysensors/const.py | 4 +- homeassistant/components/mysensors/device.py | 10 +- homeassistant/components/mysensors/gateway.py | 132 ++---------------- homeassistant/components/mysensors/handler.py | 104 ++++++++++++++ homeassistant/components/mysensors/helpers.py | 81 +++++++++++ requirements_all.txt | 2 +- 8 files changed, 216 insertions(+), 130 deletions(-) create mode 100644 homeassistant/components/mysensors/handler.py create mode 100644 homeassistant/components/mysensors/helpers.py diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index 49d3f3207ba..8b10bc2b9bb 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -19,11 +19,16 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): return False for device in new_devices: + gateway_id = id(device.gateway) dev_id = ( - id(device.gateway), device.node_id, device.child_id, + gateway_id, device.node_id, device.child_id, device.value_type) async_dispatcher_connect( - hass, mysensors.const.SIGNAL_CALLBACK.format(*dev_id), + hass, mysensors.const.CHILD_CALLBACK.format(*dev_id), + device.async_update_callback) + async_dispatcher_connect( + hass, + mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id), device.async_update_callback) return True diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 883175340ce..49f8560c6b3 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -22,7 +22,7 @@ from .const import ( from .device import get_mysensors_devices from .gateway import get_mysensors_gateway, setup_gateways, finish_setup -REQUIREMENTS = ['pymysensors==0.17.0'] +REQUIREMENTS = ['pymysensors==0.18.0'] _LOGGER = logging.getLogger(__name__) @@ -135,7 +135,7 @@ def setup_mysensors_platform( # Only act if called via MySensors by discovery event. # Otherwise gateway is not set up. if not discovery_info: - return + return None if device_args is None: device_args = () new_devices = [] diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 4f9718a39db..ccb54bf647f 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -16,10 +16,12 @@ CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' CONF_VERSION = 'version' DOMAIN = 'mysensors' +MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' MYSENSORS_GATEWAYS = 'mysensors_gateways' PLATFORM = 'platform' SCHEMA = 'schema' -SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' +CHILD_CALLBACK = 'mysensors_child_callback_{}_{}_{}_{}' +NODE_CALLBACK = 'mysensors_node_callback_{}_{}' TYPE = 'type' # MySensors const schemas diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 3ae99f61d17..7f4f6100204 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -7,7 +7,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import SIGNAL_CALLBACK +from .const import CHILD_CALLBACK, NODE_CALLBACK _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,11 @@ class MySensorsEntity(MySensorsDevice, Entity): async def async_added_to_hass(self): """Register update callback.""" - dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type + gateway_id = id(self.gateway) + dev_id = gateway_id, self.node_id, self.child_id, self.value_type async_dispatcher_connect( - self.hass, SIGNAL_CALLBACK.format(*dev_id), + self.hass, CHILD_CALLBACK.format(*dev_id), + self.async_update_callback) + async_dispatcher_connect( + self.hass, NODE_CALLBACK.format(gateway_id, self.node_id), self.async_update_callback) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index cb1dad922f8..d4a52655d19 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -4,32 +4,28 @@ from collections import defaultdict import logging import socket import sys -from timeit import default_timer as timer import async_timeout import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP) + CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component from .const import ( - ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, CONF_NODES, + CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, CONF_NODES, CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, DOMAIN, - MYSENSORS_CONST_SCHEMA, MYSENSORS_GATEWAYS, PLATFORM, SCHEMA, - SIGNAL_CALLBACK, TYPE) -from .device import get_mysensors_devices + MYSENSORS_GATEWAY_READY, MYSENSORS_GATEWAYS) +from .handler import HANDLERS +from .helpers import discover_mysensors_platform, validate_child _LOGGER = logging.getLogger(__name__) GATEWAY_READY_TIMEOUT = 15.0 MQTT_COMPONENT = 'mqtt' -MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' def is_serial_port(value): @@ -167,25 +163,16 @@ async def _discover_persistent_devices(hass, hass_config, gateway): for node_id in gateway.sensors: node = gateway.sensors[node_id] for child in node.children.values(): - validated = _validate_child(gateway, node_id, child) + validated = validate_child(gateway, node_id, child) for platform, dev_ids in validated.items(): new_devices[platform].extend(dev_ids) for platform, dev_ids in new_devices.items(): - tasks.append(_discover_mysensors_platform( + tasks.append(discover_mysensors_platform( hass, hass_config, platform, dev_ids)) if tasks: await asyncio.wait(tasks, loop=hass.loop) -@callback -def _discover_mysensors_platform(hass, hass_config, platform, new_devices): - """Discover a MySensors platform.""" - task = hass.async_create_task(discovery.async_load_platform( - hass, platform, DOMAIN, - {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}, hass_config)) - return task - - async def _gw_start(hass, gateway): """Start the gateway.""" # Don't use hass.async_create_task to avoid holding up setup indefinitely. @@ -222,112 +209,15 @@ def _gw_callback_factory(hass, hass_config): @callback def mysensors_callback(msg): """Handle messages from a MySensors gateway.""" - start = timer() _LOGGER.debug( "Node update: node %s child %s", msg.node_id, msg.child_id) - _set_gateway_ready(hass, msg) + msg_type = msg.gateway.const.MessageType(msg.type) + msg_handler = HANDLERS.get(msg_type.name) - try: - child = msg.gateway.sensors[msg.node_id].children[msg.child_id] - except KeyError: - _LOGGER.debug("Not a child update for node %s", msg.node_id) + if msg_handler is None: return - signals = [] + hass.async_create_task(msg_handler(hass, hass_config, msg)) - # Update all platforms for the device via dispatcher. - # Add/update entity if schema validates to true. - validated = _validate_child(msg.gateway, msg.node_id, child) - for platform, dev_ids in validated.items(): - devices = get_mysensors_devices(hass, platform) - new_dev_ids = [] - for dev_id in dev_ids: - if dev_id in devices: - signals.append(SIGNAL_CALLBACK.format(*dev_id)) - else: - new_dev_ids.append(dev_id) - if new_dev_ids: - _discover_mysensors_platform( - hass, hass_config, platform, new_dev_ids) - for signal in set(signals): - # Only one signal per device is needed. - # A device can have multiple platforms, ie multiple schemas. - # FOR LATER: Add timer to not signal if another update comes in. - async_dispatcher_send(hass, signal) - end = timer() - if end - start > 0.1: - _LOGGER.debug( - "Callback for node %s child %s took %.3f seconds", - msg.node_id, msg.child_id, end - start) return mysensors_callback - - -@callback -def _set_gateway_ready(hass, msg): - """Set asyncio future result if gateway is ready.""" - if (msg.type != msg.gateway.const.MessageType.internal or - msg.sub_type != msg.gateway.const.Internal.I_GATEWAY_READY): - return - gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format( - id(msg.gateway))) - if gateway_ready is None or gateway_ready.cancelled(): - return - gateway_ready.set_result(True) - - -def _validate_child(gateway, node_id, child): - """Validate that a child has the correct values according to schema. - - Return a dict of platform with a list of device ids for validated devices. - """ - validated = defaultdict(list) - - if not child.values: - _LOGGER.debug( - "No child values for node %s child %s", node_id, child.id) - return validated - if gateway.sensors[node_id].sketch_name is None: - _LOGGER.debug("Node %s is missing sketch name", node_id) - return validated - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - s_name = next( - (member.name for member in pres if member.value == child.type), None) - if s_name not in MYSENSORS_CONST_SCHEMA: - _LOGGER.warning("Child type %s is not supported", s_name) - return validated - child_schemas = MYSENSORS_CONST_SCHEMA[s_name] - - def msg(name): - """Return a message for an invalid schema.""" - return "{} requires value_type {}".format( - pres(child.type).name, set_req[name].name) - - for schema in child_schemas: - platform = schema[PLATFORM] - v_name = schema[TYPE] - value_type = next( - (member.value for member in set_req if member.name == v_name), - None) - if value_type is None: - continue - _child_schema = child.get_schema(gateway.protocol_version) - vol_schema = _child_schema.extend( - {vol.Required(set_req[key].value, msg=msg(key)): - _child_schema.schema.get(set_req[key].value, val) - for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, - extra=vol.ALLOW_EXTRA) - try: - vol_schema(child.values) - except vol.Invalid as exc: - level = (logging.WARNING if value_type in child.values - else logging.DEBUG) - _LOGGER.log( - level, - "Invalid values: %s: %s platform: node %s child %s: %s", - child.values, platform, node_id, child.id, exc) - continue - dev_id = id(gateway), node_id, child.id, value_type - validated[platform].append(dev_id) - return validated diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py new file mode 100644 index 00000000000..3403c589639 --- /dev/null +++ b/homeassistant/components/mysensors/handler.py @@ -0,0 +1,104 @@ +"""Handle MySensors messages.""" +import logging + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import decorator + +from .const import MYSENSORS_GATEWAY_READY, CHILD_CALLBACK, NODE_CALLBACK +from .device import get_mysensors_devices +from .helpers import discover_mysensors_platform, validate_child + +_LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + + +@HANDLERS.register('presentation') +async def handle_presentation(hass, hass_config, msg): + """Handle a mysensors presentation message.""" + # Handle both node and child presentation. + from mysensors.const import SYSTEM_CHILD_ID + if msg.child_id == SYSTEM_CHILD_ID: + return + _handle_child_update(hass, hass_config, msg) + + +@HANDLERS.register('set') +async def handle_set(hass, hass_config, msg): + """Handle a mysensors set message.""" + _handle_child_update(hass, hass_config, msg) + + +@HANDLERS.register('internal') +async def handle_internal(hass, hass_config, msg): + """Handle a mysensors internal message.""" + internal = msg.gateway.const.Internal(msg.sub_type) + handler = HANDLERS.get(internal.name) + if handler is None: + return + await handler(hass, hass_config, msg) + + +@HANDLERS.register('I_BATTERY_LEVEL') +async def handle_battery_level(hass, hass_config, msg): + """Handle an internal battery level message.""" + _handle_node_update(hass, msg) + + +@HANDLERS.register('I_SKETCH_NAME') +async def handle_sketch_name(hass, hass_config, msg): + """Handle an internal sketch name message.""" + _handle_node_update(hass, msg) + + +@HANDLERS.register('I_SKETCH_VERSION') +async def handle_sketch_version(hass, hass_config, msg): + """Handle an internal sketch version message.""" + _handle_node_update(hass, msg) + + +@HANDLERS.register('I_GATEWAY_READY') +async def handle_gateway_ready(hass, hass_config, msg): + """Handle an internal gateway ready message. + + Set asyncio future result if gateway is ready. + """ + gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format( + id(msg.gateway))) + if gateway_ready is None or gateway_ready.cancelled(): + return + gateway_ready.set_result(True) + + +@callback +def _handle_child_update(hass, hass_config, msg): + """Handle a child update.""" + child = msg.gateway.sensors[msg.node_id].children[msg.child_id] + signals = [] + + # Update all platforms for the device via dispatcher. + # Add/update entity if schema validates to true. + validated = validate_child(msg.gateway, msg.node_id, child) + for platform, dev_ids in validated.items(): + devices = get_mysensors_devices(hass, platform) + new_dev_ids = [] + for dev_id in dev_ids: + if dev_id in devices: + signals.append(CHILD_CALLBACK.format(*dev_id)) + else: + new_dev_ids.append(dev_id) + if new_dev_ids: + discover_mysensors_platform( + hass, hass_config, platform, new_dev_ids) + for signal in set(signals): + # Only one signal per device is needed. + # A device can have multiple platforms, ie multiple schemas. + # FOR LATER: Add timer to not signal if another update comes in. + async_dispatcher_send(hass, signal) + + +@callback +def _handle_node_update(hass, msg): + """Handle a node update.""" + signal = NODE_CALLBACK.format(id(msg.gateway), msg.node_id) + async_dispatcher_send(hass, signal) diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py new file mode 100644 index 00000000000..a49967cf835 --- /dev/null +++ b/homeassistant/components/mysensors/helpers.py @@ -0,0 +1,81 @@ +"""Helper functions for mysensors package.""" +from collections import defaultdict +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_DEVICES, DOMAIN, MYSENSORS_CONST_SCHEMA, PLATFORM, SCHEMA, TYPE) + +_LOGGER = logging.getLogger(__name__) + + +@callback +def discover_mysensors_platform(hass, hass_config, platform, new_devices): + """Discover a MySensors platform.""" + task = hass.async_create_task(discovery.async_load_platform( + hass, platform, DOMAIN, + {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}, hass_config)) + return task + + +def validate_child(gateway, node_id, child): + """Validate that a child has the correct values according to schema. + + Return a dict of platform with a list of device ids for validated devices. + """ + validated = defaultdict(list) + + if not child.values: + _LOGGER.debug( + "No child values for node %s child %s", node_id, child.id) + return validated + if gateway.sensors[node_id].sketch_name is None: + _LOGGER.debug("Node %s is missing sketch name", node_id) + return validated + pres = gateway.const.Presentation + set_req = gateway.const.SetReq + s_name = next( + (member.name for member in pres if member.value == child.type), None) + if s_name not in MYSENSORS_CONST_SCHEMA: + _LOGGER.warning("Child type %s is not supported", s_name) + return validated + child_schemas = MYSENSORS_CONST_SCHEMA[s_name] + + def msg(name): + """Return a message for an invalid schema.""" + return "{} requires value_type {}".format( + pres(child.type).name, set_req[name].name) + + for schema in child_schemas: + platform = schema[PLATFORM] + v_name = schema[TYPE] + value_type = next( + (member.value for member in set_req if member.name == v_name), + None) + if value_type is None: + continue + _child_schema = child.get_schema(gateway.protocol_version) + vol_schema = _child_schema.extend( + {vol.Required(set_req[key].value, msg=msg(key)): + _child_schema.schema.get(set_req[key].value, val) + for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, + extra=vol.ALLOW_EXTRA) + try: + vol_schema(child.values) + except vol.Invalid as exc: + level = (logging.WARNING if value_type in child.values + else logging.DEBUG) + _LOGGER.log( + level, + "Invalid values: %s: %s platform: node %s child %s: %s", + child.values, platform, node_id, child.id, exc) + continue + dev_id = id(gateway), node_id, child.id, value_type + validated[platform].append(dev_id) + return validated diff --git a/requirements_all.txt b/requirements_all.txt index a11969c4081..9b646695a11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ pymusiccast==0.1.6 pymyq==0.0.15 # homeassistant.components.mysensors -pymysensors==0.17.0 +pymysensors==0.18.0 # homeassistant.components.lock.nello pynello==1.5.1 From 959fa81ea62b02c6bb2999e0e2304c994544fdb5 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Sun, 4 Nov 2018 22:04:51 +0100 Subject: [PATCH 018/238] Fix temperature interval Thermostat HomeKit (#18192) * Will round to nearest .0 or .5 --- homeassistant/components/homekit/const.py | 1 + .../components/homekit/type_thermostats.py | 26 +++++++++----- homeassistant/components/homekit/util.py | 4 +-- .../homekit/test_type_thermostats.py | 34 +++++++++++-------- tests/components/homekit/test_util.py | 4 +-- 5 files changed, 42 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index d35d38c6455..0b4cdf15fb5 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -127,6 +127,7 @@ CHAR_VALVE_TYPE = 'ValveType' # #### Properties #### PROP_MAX_VALUE = 'maxValue' PROP_MIN_VALUE = 'minValue' +PROP_MIN_STEP = 'minStep' PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} # #### Device Classes #### diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 49da6db6125..f78a05b1a45 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -28,7 +28,7 @@ from .const import ( CHAR_HEATING_THRESHOLD_TEMPERATURE, CHAR_TARGET_TEMPERATURE, CHAR_TEMP_DISPLAY_UNITS, DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, - PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_THERMOSTAT) + PROP_MAX_VALUE, PROP_MIN_STEP, PROP_MIN_VALUE, SERV_THERMOSTAT) from .util import temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -83,7 +83,8 @@ class Thermostat(HomeAccessory): self.char_target_temp = serv_thermostat.configure_char( CHAR_TARGET_TEMPERATURE, value=21.0, properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5}, setter_callback=self.set_target_temperature) # Display units characteristic @@ -97,13 +98,15 @@ class Thermostat(HomeAccessory): self.char_cooling_thresh_temp = serv_thermostat.configure_char( CHAR_COOLING_THRESHOLD_TEMPERATURE, value=23.0, properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5}, setter_callback=self.set_cooling_threshold) if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars: self.char_heating_thresh_temp = serv_thermostat.configure_char( CHAR_HEATING_THRESHOLD_TEMPERATURE, value=19.0, properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5}, setter_callback=self.set_heating_threshold) def get_temperature_range(self): @@ -112,11 +115,13 @@ class Thermostat(HomeAccessory): .attributes.get(ATTR_MAX_TEMP) max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \ else DEFAULT_MAX_TEMP + max_temp = round(max_temp * 2) / 2 min_temp = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_MIN_TEMP) min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \ else DEFAULT_MIN_TEMP + min_temp = round(min_temp * 2) / 2 return min_temp, max_temp @@ -140,7 +145,7 @@ class Thermostat(HomeAccessory): @debounce def set_cooling_threshold(self, value): """Set cooling threshold temp to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set cooling threshold temperature to %.2f°C', + _LOGGER.debug('%s: Set cooling threshold temperature to %.1f°C', self.entity_id, value) self._flag_coolingthresh = True low = self.char_heating_thresh_temp.value @@ -156,7 +161,7 @@ class Thermostat(HomeAccessory): @debounce def set_heating_threshold(self, value): """Set heating threshold temp to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set heating threshold temperature to %.2f°C', + _LOGGER.debug('%s: Set heating threshold temperature to %.1f°C', self.entity_id, value) self._flag_heatingthresh = True high = self.char_cooling_thresh_temp.value @@ -172,7 +177,7 @@ class Thermostat(HomeAccessory): @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set target temperature to %.2f°C', + _LOGGER.debug('%s: Set target temperature to %.1f°C', self.entity_id, value) self._flag_temperature = True temperature = temperature_to_states(value, self._unit) @@ -301,7 +306,8 @@ class WaterHeater(HomeAccessory): self.char_target_temp = serv_thermostat.configure_char( CHAR_TARGET_TEMPERATURE, value=50.0, properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5}, setter_callback=self.set_target_temperature) self.char_display_units = serv_thermostat.configure_char( @@ -313,11 +319,13 @@ class WaterHeater(HomeAccessory): .attributes.get(ATTR_MAX_TEMP) max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \ else DEFAULT_MAX_TEMP_WATER_HEATER + max_temp = round(max_temp * 2) / 2 min_temp = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_MIN_TEMP) min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \ else DEFAULT_MIN_TEMP_WATER_HEATER + min_temp = round(min_temp * 2) / 2 return min_temp, max_temp @@ -332,7 +340,7 @@ class WaterHeater(HomeAccessory): @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set target temperature to %.2f°C', + _LOGGER.debug('%s: Set target temperature to %.1f°C', self.entity_id, value) self._flag_temperature = True temperature = temperature_to_states(value, self._unit) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 43ae4df3b50..10fdc07e7b4 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -135,12 +135,12 @@ def convert_to_float(state): def temperature_to_homekit(temperature, unit): """Convert temperature to Celsius for HomeKit.""" - return round(temp_util.convert(temperature, unit, TEMP_CELSIUS), 1) + return round(temp_util.convert(temperature, unit, TEMP_CELSIUS) * 2) / 2 def temperature_to_states(temperature, unit): """Convert temperature back from Celsius to Home Assistant unit.""" - return round(temp_util.convert(temperature, TEMP_CELSIUS, unit), 1) + return round(temp_util.convert(temperature, TEMP_CELSIUS, unit) * 2) / 2 def density_to_air_quality(density): diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 795cb5db7d2..f645cddf730 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -11,7 +11,7 @@ from homeassistant.components.climate import ( DOMAIN as DOMAIN_CLIMATE, STATE_AUTO, STATE_COOL, STATE_HEAT) from homeassistant.components.homekit.const import ( ATTR_VALUE, DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, - PROP_MAX_VALUE, PROP_MIN_VALUE) + PROP_MAX_VALUE, PROP_MIN_STEP, PROP_MIN_VALUE) from homeassistant.components.water_heater import ( DOMAIN as DOMAIN_WATER_HEATER) from homeassistant.const import ( @@ -48,6 +48,7 @@ async def test_thermostat(hass, hk_driver, cls, events): assert acc.aid == 2 assert acc.category == 9 # Thermostat + assert acc.get_temperature_range() == (7.0, 35.0) assert acc.char_current_heat_cool.value == 0 assert acc.char_target_heat_cool.value == 0 assert acc.char_current_temp.value == 21.0 @@ -58,11 +59,12 @@ async def test_thermostat(hass, hk_driver, cls, events): assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP assert acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP + assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.5 hass.states.async_set(entity_id, STATE_HEAT, {ATTR_OPERATION_MODE: STATE_HEAT, - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 18.0}) + ATTR_TEMPERATURE: 22.2, + ATTR_CURRENT_TEMPERATURE: 17.8}) await hass.async_block_till_done() assert acc.char_target_temp.value == 22.0 assert acc.char_current_heat_cool.value == 1 @@ -193,10 +195,12 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): == DEFAULT_MAX_TEMP assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] \ == DEFAULT_MIN_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.5 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] \ == DEFAULT_MAX_TEMP assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] \ == DEFAULT_MIN_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.5 hass.states.async_set(entity_id, STATE_AUTO, {ATTR_OPERATION_MODE: STATE_AUTO, @@ -339,10 +343,11 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): hass.states.async_set(entity_id, STATE_AUTO, {ATTR_OPERATION_MODE: STATE_AUTO, ATTR_TARGET_TEMP_HIGH: 75.2, - ATTR_TARGET_TEMP_LOW: 68, + ATTR_TARGET_TEMP_LOW: 68.1, ATTR_TEMPERATURE: 71.6, ATTR_CURRENT_TEMPERATURE: 73.4}) await hass.async_block_till_done() + assert acc.get_temperature_range() == (7.0, 35.0) assert acc.char_heating_thresh_temp.value == 20.0 assert acc.char_cooling_thresh_temp.value == 24.0 assert acc.char_current_temp.value == 23.0 @@ -358,28 +363,28 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): await hass.async_block_till_done() assert call_set_temperature[0] assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.5 assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68 assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == 'cooling threshold 73.4°F' + assert events[-1].data[ATTR_VALUE] == 'cooling threshold 73.5°F' await hass.async_add_job( acc.char_heating_thresh_temp.client_update_value, 22) await hass.async_block_till_done() assert call_set_temperature[1] assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.4 - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.6 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.5 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.5 assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] == 'heating threshold 71.6°F' + assert events[-1].data[ATTR_VALUE] == 'heating threshold 71.5°F' await hass.async_add_job(acc.char_target_temp.client_update_value, 24.0) await hass.async_block_till_done() assert call_set_temperature[2] assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.2 + assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.0 assert len(events) == 3 - assert events[-1].data[ATTR_VALUE] == '75.2°F' + assert events[-1].data[ATTR_VALUE] == '75.0°F' async def test_thermostat_get_temperature_range(hass, hk_driver, cls): @@ -399,7 +404,7 @@ async def test_thermostat_get_temperature_range(hass, hk_driver, cls): hass.states.async_set(entity_id, STATE_OFF, {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}) await hass.async_block_till_done() - assert acc.get_temperature_range() == (15.6, 21.1) + assert acc.get_temperature_range() == (15.5, 21.0) async def test_water_heater(hass, hk_driver, cls, events): @@ -425,6 +430,7 @@ async def test_water_heater(hass, hk_driver, cls, events): DEFAULT_MAX_TEMP_WATER_HEATER assert acc.char_target_temp.properties[PROP_MIN_VALUE] == \ DEFAULT_MIN_TEMP_WATER_HEATER + assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.5 hass.states.async_set(entity_id, STATE_HEAT, {ATTR_OPERATION_MODE: STATE_HEAT, @@ -508,7 +514,7 @@ async def test_water_heater_get_temperature_range(hass, hk_driver, cls): hass.states.async_set(entity_id, STATE_HEAT) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, 'WaterHeater', entity_id, 2, None) hass.states.async_set(entity_id, STATE_HEAT, {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25}) @@ -519,4 +525,4 @@ async def test_water_heater_get_temperature_range(hass, hk_driver, cls): hass.states.async_set(entity_id, STATE_OFF, {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}) await hass.async_block_till_done() - assert acc.get_temperature_range() == (15.6, 21.1) + assert acc.get_temperature_range() == (15.5, 21.0) diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 0368dfa642e..a2849a77396 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -99,13 +99,13 @@ def test_convert_to_float(): def test_temperature_to_homekit(): """Test temperature conversion from HA to HomeKit.""" assert temperature_to_homekit(20.46, TEMP_CELSIUS) == 20.5 - assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.4 + assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.5 def test_temperature_to_states(): """Test temperature conversion from HomeKit to HA.""" assert temperature_to_states(20, TEMP_CELSIUS) == 20.0 - assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.4 + assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.5 def test_density_to_air_quality(): From 922f34f72df878021f8272acbdaa0a94cd11e5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 4 Nov 2018 23:46:42 +0200 Subject: [PATCH 019/238] Add more type hints to helpers (#18196) * Test typing for helpers.__init__ and temperature * Add type hints to helpers.sun * Add type hints to helpers.signal * Add type hints to helpers.entity_values * Add type hints to helpers.dispatcher --- homeassistant/helpers/dispatcher.py | 17 ++++++++----- homeassistant/helpers/entity_values.py | 13 +++++----- homeassistant/helpers/signal.py | 5 ++-- homeassistant/helpers/sun.py | 34 +++++++++++++++++--------- tox.ini | 2 +- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 136f4caa35a..a28cd3d6392 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -1,9 +1,11 @@ """Helpers for Home Assistant dispatcher & internal component/platform.""" import logging +from typing import Any, Callable from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe +from .typing import HomeAssistantType _LOGGER = logging.getLogger(__name__) @@ -11,12 +13,13 @@ DATA_DISPATCHER = 'dispatcher' @bind_hass -def dispatcher_connect(hass, signal, target): +def dispatcher_connect(hass: HomeAssistantType, signal: str, + target: Callable[..., None]) -> Callable[[], None]: """Connect a callable function to a signal.""" async_unsub = run_callback_threadsafe( hass.loop, async_dispatcher_connect, hass, signal, target).result() - def remove_dispatcher(): + def remove_dispatcher() -> None: """Remove signal listener.""" run_callback_threadsafe(hass.loop, async_unsub).result() @@ -25,7 +28,8 @@ def dispatcher_connect(hass, signal, target): @callback @bind_hass -def async_dispatcher_connect(hass, signal, target): +def async_dispatcher_connect(hass: HomeAssistantType, signal: str, + target: Callable[..., Any]) -> Callable[[], None]: """Connect a callable function to a signal. This method must be run in the event loop. @@ -39,7 +43,7 @@ def async_dispatcher_connect(hass, signal, target): hass.data[DATA_DISPATCHER][signal].append(target) @callback - def async_remove_dispatcher(): + def async_remove_dispatcher() -> None: """Remove signal listener.""" try: hass.data[DATA_DISPATCHER][signal].remove(target) @@ -53,14 +57,15 @@ def async_dispatcher_connect(hass, signal, target): @bind_hass -def dispatcher_send(hass, signal, *args): +def dispatcher_send(hass: HomeAssistantType, signal: str, *args: Any) -> None: """Send signal and data.""" hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) @callback @bind_hass -def async_dispatcher_send(hass, signal, *args): +def async_dispatcher_send( + hass: HomeAssistantType, signal: str, *args: Any) -> None: """Send signal and data. This method must be run in the event loop. diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py index 77739f8adab..caf580ebc75 100644 --- a/homeassistant/helpers/entity_values.py +++ b/homeassistant/helpers/entity_values.py @@ -2,7 +2,7 @@ from collections import OrderedDict import fnmatch import re -from typing import Dict +from typing import Any, Dict, Optional, Pattern # noqa: F401 from homeassistant.core import split_entity_id @@ -10,15 +10,16 @@ from homeassistant.core import split_entity_id class EntityValues: """Class to store entity id based values.""" - def __init__(self, exact: Dict = None, domain: Dict = None, - glob: Dict = None) -> None: + def __init__(self, exact: Optional[Dict] = None, + domain: Optional[Dict] = None, + glob: Optional[Dict] = None) -> None: """Initialize an EntityConfigDict.""" - self._cache = {} + self._cache = {} # type: Dict[str, Dict] self._exact = exact self._domain = domain if glob is None: - compiled = None + compiled = None # type: Optional[Dict[Pattern[str], Any]] else: compiled = OrderedDict() for key, value in glob.items(): @@ -26,7 +27,7 @@ class EntityValues: self._glob = compiled - def get(self, entity_id): + def get(self, entity_id: str) -> Dict: """Get config for an entity id.""" if entity_id in self._cache: return self._cache[entity_id] diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 7496388fb52..ffb6197ab66 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -2,6 +2,7 @@ import logging import signal import sys +from types import FrameType from homeassistant.core import callback, HomeAssistant from homeassistant.const import RESTART_EXIT_CODE @@ -16,7 +17,7 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: """Register system signal handler for core.""" if sys.platform != 'win32': @callback - def async_signal_handle(exit_code): + def async_signal_handle(exit_code: int) -> None: """Wrap signal handling. * queue call to shutdown task @@ -49,7 +50,7 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: old_sigint = None @callback - def async_signal_handle(exit_code, frame): + def async_signal_handle(exit_code: int, frame: FrameType) -> None: """Wrap signal handling. * queue call to shutdown task diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 99ea1bad11f..049359a7313 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -1,23 +1,28 @@ """Helpers for sun events.""" import datetime +from typing import Optional, Union, TYPE_CHECKING from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import callback from homeassistant.util import dt as dt_util from homeassistant.loader import bind_hass +from .typing import HomeAssistantType + +if TYPE_CHECKING: + import astral # pylint: disable=unused-import DATA_LOCATION_CACHE = 'astral_location_cache' @callback @bind_hass -def get_astral_location(hass): +def get_astral_location(hass: HomeAssistantType) -> 'astral.Location': """Get an astral location for the current Home Assistant configuration.""" from astral import Location latitude = hass.config.latitude longitude = hass.config.longitude - timezone = hass.config.time_zone.zone + timezone = str(hass.config.time_zone) elevation = hass.config.elevation info = ('', '', latitude, longitude, timezone, elevation) @@ -33,9 +38,12 @@ def get_astral_location(hass): @callback @bind_hass -def get_astral_event_next(hass, event, utc_point_in_time=None, offset=None): +def get_astral_event_next( + hass: HomeAssistantType, event: str, + utc_point_in_time: Optional[datetime.datetime] = None, + offset: Optional[datetime.timedelta] = None) -> datetime.datetime: """Calculate the next specified solar event.""" - import astral + from astral import AstralError location = get_astral_location(hass) @@ -51,19 +59,22 @@ def get_astral_event_next(hass, event, utc_point_in_time=None, offset=None): next_dt = getattr(location, event)( dt_util.as_local(utc_point_in_time).date() + datetime.timedelta(days=mod), - local=False) + offset + local=False) + offset # type: datetime.datetime if next_dt > utc_point_in_time: return next_dt - except astral.AstralError: + except AstralError: pass mod += 1 @callback @bind_hass -def get_astral_event_date(hass, event, date=None): +def get_astral_event_date( + hass: HomeAssistantType, event: str, + date: Union[datetime.date, datetime.datetime, None] = None) \ + -> Optional[datetime.datetime]: """Calculate the astral event time for the specified date.""" - import astral + from astral import AstralError location = get_astral_location(hass) @@ -74,15 +85,16 @@ def get_astral_event_date(hass, event, date=None): date = dt_util.as_local(date).date() try: - return getattr(location, event)(date, local=False) - except astral.AstralError: + return getattr(location, event)(date, local=False) # type: ignore + except AstralError: # Event never occurs for specified date. return None @callback @bind_hass -def is_up(hass, utc_point_in_time=None): +def is_up(hass: HomeAssistantType, + utc_point_in_time: Optional[datetime.datetime] = None) -> bool: """Calculate if the sun is currently up.""" if utc_point_in_time is None: utc_point_in_time = dt_util.utcnow() diff --git a/tox.ini b/tox.ini index 4a44feb6c7f..f5dee78893f 100644 --- a/tox.ini +++ b/tox.ini @@ -60,4 +60,4 @@ whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{icon,intent,json,location,state,translation,typing}.py' + /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,dispatcher,entity_values,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' From 1c3ef8be55fa739e049904805f5e47d3dba3b72c Mon Sep 17 00:00:00 2001 From: Andrea Tosatto Date: Mon, 5 Nov 2018 02:09:29 +0100 Subject: [PATCH 020/238] Implemented tplink_lte components and notify service via SMS (#17111) * Implemented tplink_lte components and notify service * Device discovery for the notify component * Improved the config schema. Small fixes * Improved login retry mechanism * Log successful connection only on retries * Removed CancelledError handlers and small fixes --- .coveragerc | 3 + homeassistant/components/notify/tplink_lte.py | 50 ++++++ homeassistant/components/tplink_lte.py | 150 ++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 206 insertions(+) create mode 100644 homeassistant/components/notify/tplink_lte.py create mode 100644 homeassistant/components/tplink_lte.py diff --git a/.coveragerc b/.coveragerc index f5e9545625b..f5103867688 100644 --- a/.coveragerc +++ b/.coveragerc @@ -333,6 +333,9 @@ omit = homeassistant/components/toon.py homeassistant/components/*/toon.py + homeassistant/components/tplink_lte.py + homeassistant/components/*/tplink_lte.py + homeassistant/components/tradfri.py homeassistant/components/*/tradfri.py diff --git a/homeassistant/components/notify/tplink_lte.py b/homeassistant/components/notify/tplink_lte.py new file mode 100644 index 00000000000..9bb80e2591c --- /dev/null +++ b/homeassistant/components/notify/tplink_lte.py @@ -0,0 +1,50 @@ +"""TP-Link LTE platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.tplink_lte/ +""" + +import logging + +import attr + +from homeassistant.components.notify import ( + ATTR_TARGET, BaseNotificationService) + +from ..tplink_lte import DATA_KEY + +DEPENDENCIES = ['tplink_lte'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_service(hass, config, discovery_info=None): + """Get the notification service.""" + if discovery_info is None: + return + return TplinkNotifyService(hass, discovery_info) + + +@attr.s +class TplinkNotifyService(BaseNotificationService): + """Implementation of a notification service.""" + + hass = attr.ib() + config = attr.ib() + + async def async_send_message(self, message="", **kwargs): + """Send a message to a user.""" + import tp_connected + modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config) + if not modem_data: + _LOGGER.error("No modem available") + return + + phone = self.config[ATTR_TARGET] + targets = kwargs.get(ATTR_TARGET, phone) + if targets and message: + for target in targets: + try: + await modem_data.modem.sms(target, message) + except tp_connected.Error: + _LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/tplink_lte.py b/homeassistant/components/tplink_lte.py new file mode 100644 index 00000000000..17288a881aa --- /dev/null +++ b/homeassistant/components/tplink_lte.py @@ -0,0 +1,150 @@ +""" +Support for TP-Link LTE modems. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tplink_lte/ +""" +import asyncio +import logging + +import aiohttp +import attr +import voluptuous as vol + +from homeassistant.components.notify import ATTR_TARGET +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +REQUIREMENTS = ['tp-connected==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'tplink_lte' +DATA_KEY = 'tplink_lte' + +CONF_NOTIFY = "notify" + +_NOTIFY_SCHEMA = vol.All(vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), +})) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NOTIFY): + vol.All(cv.ensure_list, [_NOTIFY_SCHEMA]), + })]) +}, extra=vol.ALLOW_EXTRA) + + +@attr.s +class ModemData: + """Class for modem state.""" + + host = attr.ib() + modem = attr.ib() + + connected = attr.ib(init=False, default=True) + + +@attr.s +class LTEData: + """Shared state.""" + + websession = attr.ib() + modem_data = attr.ib(init=False, factory=dict) + + def get_modem_data(self, config): + """Get the requested or the only modem_data value.""" + if CONF_HOST in config: + return self.modem_data.get(config[CONF_HOST]) + if len(self.modem_data) == 1: + return next(iter(self.modem_data.values())) + + return None + + +async def async_setup(hass, config): + """Set up TP-Link LTE component.""" + if DATA_KEY not in hass.data: + websession = async_create_clientsession( + hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) + hass.data[DATA_KEY] = LTEData(websession) + + domain_config = config.get(DOMAIN, []) + + tasks = [_setup_lte(hass, conf) for conf in domain_config] + if tasks: + await asyncio.wait(tasks) + + for conf in domain_config: + for notify_conf in conf.get(CONF_NOTIFY, []): + hass.async_create_task(discovery.async_load_platform( + hass, 'notify', DOMAIN, notify_conf, config)) + + return True + + +async def _setup_lte(hass, lte_config, delay=0): + """Set up a TP-Link LTE modem.""" + import tp_connected + + host = lte_config[CONF_HOST] + password = lte_config[CONF_PASSWORD] + + websession = hass.data[DATA_KEY].websession + modem = tp_connected.Modem(hostname=host, websession=websession) + + modem_data = ModemData(host, modem) + + try: + await _login(hass, modem_data, password) + except tp_connected.Error: + retry_task = hass.loop.create_task( + _retry_login(hass, modem_data, password)) + + @callback + def cleanup_retry(event): + """Clean up retry task resources.""" + if not retry_task.done(): + retry_task.cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) + + +async def _login(hass, modem_data, password): + """Log in and complete setup.""" + await modem_data.modem.login(password=password) + modem_data.connected = True + hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data + + async def cleanup(event): + """Clean up resources.""" + await modem_data.modem.logout() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + + +async def _retry_login(hass, modem_data, password): + """Sleep and retry setup.""" + import tp_connected + + _LOGGER.warning( + "Could not connect to %s. Will keep trying.", modem_data.host) + + modem_data.connected = False + delay = 15 + + while not modem_data.connected: + await asyncio.sleep(delay) + + try: + await _login(hass, modem_data, password) + _LOGGER.warning("Connected to %s", modem_data.host) + except tp_connected.Error: + delay = min(2*delay, 300) diff --git a/requirements_all.txt b/requirements_all.txt index 9b646695a11..9f376bb0d41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1493,6 +1493,9 @@ toonlib==1.1.3 # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.20 +# homeassistant.components.tplink_lte +tp-connected==0.0.4 + # homeassistant.components.device_tracker.tplink tplink==0.2.1 From 6e4ce35a6933898574c41665380bdd9514b8a409 Mon Sep 17 00:00:00 2001 From: Dav0815 <35415680+Dav0815@users.noreply.github.com> Date: Mon, 5 Nov 2018 18:27:20 +1100 Subject: [PATCH 021/238] Add destination and icon (#18210) * Add destination and icon * Update test_transport_nsw.py * Error handling fix in external lib * Reverse sensor name change to prevent break --- .../components/sensor/transport_nsw.py | 41 +++++++++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sensor/test_transport_nsw.py | 9 +++- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/transport_nsw.py b/homeassistant/components/sensor/transport_nsw.py index 08a2907748c..2e28d81a2c3 100644 --- a/homeassistant/components/sensor/transport_nsw.py +++ b/homeassistant/components/sensor/transport_nsw.py @@ -4,6 +4,7 @@ Transport NSW (AU) sensor to query next leave event for a specified stop. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.transport_nsw/ """ +from datetime import timedelta import logging import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, CONF_API_KEY, ATTR_ATTRIBUTION) -REQUIREMENTS = ['PyTransportNSW==0.0.8'] +REQUIREMENTS = ['PyTransportNSW==0.1.1'] _LOGGER = logging.getLogger(__name__) @@ -22,19 +23,34 @@ ATTR_ROUTE = 'route' ATTR_DUE_IN = 'due' ATTR_DELAY = 'delay' ATTR_REAL_TIME = 'real_time' +ATTR_DESTINATION = 'destination' +ATTR_MODE = 'mode' CONF_ATTRIBUTION = "Data provided by Transport NSW" CONF_STOP_ID = 'stop_id' CONF_ROUTE = 'route' +CONF_DESTINATION = 'destination' DEFAULT_NAME = "Next Bus" -ICON = "mdi:bus" +ICONS = { + 'Train': 'mdi:train', + 'Lightrail': 'mdi:tram', + 'Bus': 'mdi:bus', + 'Coach': 'mdi:bus', + 'Ferry': 'mdi:ferry', + 'Schoolbus': 'mdi:bus', + 'n/a': 'mdi:clock', + None: 'mdi:clock' +} + +SCAN_INTERVAL = timedelta(seconds=60) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STOP_ID): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_ROUTE, default=""): cv.string, + vol.Optional(CONF_DESTINATION, default=""): cv.string }) @@ -43,9 +59,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): stop_id = config[CONF_STOP_ID] api_key = config[CONF_API_KEY] route = config.get(CONF_ROUTE) + destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) - data = PublicTransportData(stop_id, route, api_key) + data = PublicTransportData(stop_id, route, destination, api_key) add_entities([TransportNSWSensor(data, stop_id, name)], True) @@ -58,6 +75,7 @@ class TransportNSWSensor(Entity): self._name = name self._stop_id = stop_id self._times = self._state = None + self._icon = ICONS[None] @property def name(self): @@ -79,6 +97,8 @@ class TransportNSWSensor(Entity): ATTR_ROUTE: self._times[ATTR_ROUTE], ATTR_DELAY: self._times[ATTR_DELAY], ATTR_REAL_TIME: self._times[ATTR_REAL_TIME], + ATTR_DESTINATION: self._times[ATTR_DESTINATION], + ATTR_MODE: self._times[ATTR_MODE], ATTR_ATTRIBUTION: CONF_ATTRIBUTION } @@ -90,36 +110,43 @@ class TransportNSWSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" - return ICON + return self._icon def update(self): """Get the latest data from Transport NSW and update the states.""" self.data.update() self._times = self.data.info self._state = self._times[ATTR_DUE_IN] + self._icon = ICONS[self._times[ATTR_MODE]] class PublicTransportData: """The Class for handling the data retrieval.""" - def __init__(self, stop_id, route, api_key): + def __init__(self, stop_id, route, destination, api_key): """Initialize the data object.""" import TransportNSW self._stop_id = stop_id self._route = route + self._destination = destination self._api_key = api_key self.info = {ATTR_ROUTE: self._route, ATTR_DUE_IN: 'n/a', ATTR_DELAY: 'n/a', - ATTR_REAL_TIME: 'n/a'} + ATTR_REAL_TIME: 'n/a', + ATTR_DESTINATION: 'n/a', + ATTR_MODE: None} self.tnsw = TransportNSW.TransportNSW() def update(self): """Get the next leave time.""" _data = self.tnsw.get_departures(self._stop_id, self._route, + self._destination, self._api_key) self.info = {ATTR_ROUTE: _data['route'], ATTR_DUE_IN: _data['due'], ATTR_DELAY: _data['delay'], - ATTR_REAL_TIME: _data['real_time']} + ATTR_REAL_TIME: _data['real_time'], + ATTR_DESTINATION: _data['destination'], + ATTR_MODE: _data['mode']} diff --git a/requirements_all.txt b/requirements_all.txt index 9f376bb0d41..6bf7ff0c98e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -56,7 +56,7 @@ PyRMVtransport==0.1.3 PySwitchbot==0.3 # homeassistant.components.sensor.transport_nsw -PyTransportNSW==0.0.8 +PyTransportNSW==0.1.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75e21b33c38..7209e3b6b2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ HAP-python==2.2.2 PyRMVtransport==0.1.3 # homeassistant.components.sensor.transport_nsw -PyTransportNSW==0.0.8 +PyTransportNSW==0.1.1 # homeassistant.components.notify.yessssms YesssSMS==0.2.3 diff --git a/tests/components/sensor/test_transport_nsw.py b/tests/components/sensor/test_transport_nsw.py index c0ad4be4110..231e175893f 100644 --- a/tests/components/sensor/test_transport_nsw.py +++ b/tests/components/sensor/test_transport_nsw.py @@ -10,18 +10,21 @@ VALID_CONFIG = {'sensor': { 'platform': 'transport_nsw', 'stop_id': '209516', 'route': '199', + 'destination': '', 'api_key': 'YOUR_API_KEY'} } -def get_departuresMock(_stop_id, route, api_key): +def get_departuresMock(_stop_id, route, destination, api_key): """Mock TransportNSW departures loading.""" data = { 'stop_id': '209516', 'route': '199', 'due': 16, 'delay': 6, - 'real_time': 'y' + 'real_time': 'y', + 'destination': 'Palm Beach', + 'mode': 'Bus' } return data @@ -48,3 +51,5 @@ class TestRMVtransportSensor(unittest.TestCase): assert state.attributes['route'] == '199' assert state.attributes['delay'] == 6 assert state.attributes['real_time'] == 'y' + assert state.attributes['destination'] == 'Palm Beach' + assert state.attributes['mode'] == 'Bus' From 2e9132873a966020ff0c3621124e5f69e6a21490 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Nov 2018 09:23:58 +0100 Subject: [PATCH 022/238] Webhook names (#18206) * Add new automation_info param to async_trigger * Add domain and name to webhook registration and add WS command --- .../components/automation/__init__.py | 5 +- homeassistant/components/automation/event.py | 2 +- .../components/automation/geo_location.py | 2 +- .../components/automation/homeassistant.py | 2 +- .../components/automation/litejet.py | 2 +- homeassistant/components/automation/mqtt.py | 2 +- .../components/automation/numeric_state.py | 2 +- homeassistant/components/automation/state.py | 2 +- homeassistant/components/automation/sun.py | 2 +- .../components/automation/template.py | 2 +- homeassistant/components/automation/time.py | 2 +- .../components/automation/webhook.py | 5 +- homeassistant/components/automation/zone.py | 2 +- .../components/dialogflow/__init__.py | 2 +- homeassistant/components/ifttt/__init__.py | 2 +- homeassistant/components/mailgun/__init__.py | 2 +- homeassistant/components/twilio/__init__.py | 2 +- homeassistant/components/webhook.py | 40 +++++++++++-- tests/components/test_webhook.py | 36 ++++++++++-- tests/components/twilio/test_init.py | 57 ++++++++++--------- 20 files changed, 119 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index a1f1563f5e1..f8563071fbc 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -400,6 +400,9 @@ async def _async_process_trigger(hass, config, trigger_configs, name, action): This method is a coroutine. """ removes = [] + info = { + 'name': name + } for conf in trigger_configs: platform = await async_prepare_setup_platform( @@ -408,7 +411,7 @@ async def _async_process_trigger(hass, config, trigger_configs, name, action): if platform is None: return None - remove = await platform.async_trigger(hass, conf, action) + remove = await platform.async_trigger(hass, conf, action, info) if not remove: _LOGGER.error("Error setting up trigger %s", name) diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index a9605f343fd..ec47479eac8 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) event_data_schema = vol.Schema( diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py index b2c9a9c093a..537646fefc1 100644 --- a/homeassistant/components/automation/geo_location.py +++ b/homeassistant/components/automation/geo_location.py @@ -33,7 +33,7 @@ def source_match(state, source): return state and state.attributes.get('source') == source -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" source = config.get(CONF_SOURCE).lower() zone_entity_id = config.get(CONF_ZONE) diff --git a/homeassistant/components/automation/homeassistant.py b/homeassistant/components/automation/homeassistant.py index 30ab979d6f4..6d7a44291c9 100644 --- a/homeassistant/components/automation/homeassistant.py +++ b/homeassistant/components/automation/homeassistant.py @@ -22,7 +22,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event = config.get(CONF_EVENT) diff --git a/homeassistant/components/automation/litejet.py b/homeassistant/components/automation/litejet.py index c0d2dd99ba2..70e01174078 100644 --- a/homeassistant/components/automation/litejet.py +++ b/homeassistant/components/automation/litejet.py @@ -32,7 +32,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" number = config.get(CONF_NUMBER) held_more_than = config.get(CONF_HELD_MORE_THAN) diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 99d5ab8674c..67c538154e5 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" topic = config.get(CONF_TOPIC) payload = config.get(CONF_PAYLOAD) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 675b6f3653a..aa51e631026 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -28,7 +28,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({ _LOGGER = logging.getLogger(__name__) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 46c5cafa071..4e47026d8d1 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({ }), cv.key_dependency(CONF_FOR, CONF_TO)) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) from_state = config.get(CONF_FROM, MATCH_ALL) diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index 7cefe6953a1..509195689a1 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event = config.get(CONF_EVENT) offset = config.get(CONF_OFFSET) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index c0d83b1067f..347b3f94e7d 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -22,7 +22,7 @@ TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index eccc31581a0..116bfbdbc97 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -28,7 +28,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({ }), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AT)) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" if CONF_AT in config: at_time = config.get(CONF_AT) diff --git a/homeassistant/components/automation/webhook.py b/homeassistant/components/automation/webhook.py index 345b0fe3249..f4afc8a601a 100644 --- a/homeassistant/components/automation/webhook.py +++ b/homeassistant/components/automation/webhook.py @@ -14,6 +14,8 @@ from homeassistant.core import callback from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID import homeassistant.helpers.config_validation as cv +from . import DOMAIN as AUTOMATION_DOMAIN + DEPENDENCIES = ('webhook',) _LOGGER = logging.getLogger(__name__) @@ -39,10 +41,11 @@ async def _handle_webhook(action, hass, webhook_id, request): hass.async_run_job(action, {'trigger': result}) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Trigger based on incoming webhooks.""" webhook_id = config.get(CONF_WEBHOOK_ID) hass.components.webhook.async_register( + AUTOMATION_DOMAIN, automation_info['name'], webhook_id, partial(_handle_webhook, action)) @callback diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index dfc9cc418bf..0c3c0941a9e 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) zone_entity_id = config.get(CONF_ZONE) diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py index 900dae5c7c1..3f3fbe7c14e 100644 --- a/homeassistant/components/dialogflow/__init__.py +++ b/homeassistant/components/dialogflow/__init__.py @@ -76,7 +76,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data[CONF_WEBHOOK_ID], handle_webhook) + DOMAIN, 'DialogFlow', entry.data[CONF_WEBHOOK_ID], handle_webhook) return True diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 85ee6b9fa1c..209bbcef607 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -88,7 +88,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data[CONF_WEBHOOK_ID], handle_webhook) + DOMAIN, 'IFTTT', entry.data[CONF_WEBHOOK_ID], handle_webhook) return True diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index e78dc0aa479..7fa08bb0f22 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -81,7 +81,7 @@ async def verify_webhook(hass, token=None, timestamp=None, signature=None): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data[CONF_WEBHOOK_ID], handle_webhook) + DOMAIN, 'Mailgun', entry.data[CONF_WEBHOOK_ID], handle_webhook) return True diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py index c28f56a4b6c..9fcba4da817 100644 --- a/homeassistant/components/twilio/__init__.py +++ b/homeassistant/components/twilio/__init__.py @@ -56,7 +56,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data[CONF_WEBHOOK_ID], handle_webhook) + DOMAIN, 'Twilio', entry.data[CONF_WEBHOOK_ID], handle_webhook) return True diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index 2a4c3f973f2..ad23ba6f544 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -6,10 +6,12 @@ https://home-assistant.io/components/webhook/ import logging from aiohttp.web import Response +import voluptuous as vol from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.auth.util import generate_secret +from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView DOMAIN = 'webhook' @@ -17,16 +19,26 @@ DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) +WS_TYPE_LIST = 'webhook/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + + @callback @bind_hass -def async_register(hass, webhook_id, handler): +def async_register(hass, domain, name, webhook_id, handler): """Register a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) if webhook_id in handlers: raise ValueError('Handler is already defined!') - handlers[webhook_id] = handler + handlers[webhook_id] = { + 'domain': domain, + 'name': name, + 'handler': handler + } @callback @@ -53,6 +65,10 @@ def async_generate_url(hass, webhook_id): async def async_setup(hass, config): """Initialize the webhook component.""" hass.http.register_view(WebhookView) + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list, + SCHEMA_WS_LIST + ) return True @@ -67,19 +83,33 @@ class WebhookView(HomeAssistantView): """Handle webhook call.""" hass = request.app['hass'] handlers = hass.data.setdefault(DOMAIN, {}) - handler = handlers.get(webhook_id) + webhook = handlers.get(webhook_id) # Always respond successfully to not give away if a hook exists or not. - if handler is None: + if webhook is None: _LOGGER.warning( 'Received message for unregistered webhook %s', webhook_id) return Response(status=200) try: - response = await handler(hass, webhook_id, request) + response = await webhook['handler'](hass, webhook_id, request) if response is None: response = Response(status=200) return response except Exception: # pylint: disable=broad-except _LOGGER.exception("Error processing webhook %s", webhook_id) return Response(status=200) + + +@callback +def websocket_list(hass, connection, msg): + """Return a list of webhooks.""" + handlers = hass.data.setdefault(DOMAIN, {}) + result = [{ + 'webhook_id': webhook_id, + 'domain': info['domain'], + 'name': info['name'], + } for webhook_id, info in handlers.items()] + + connection.send_message( + websocket_api.result_message(msg['id'], result)) diff --git a/tests/components/test_webhook.py b/tests/components/test_webhook.py index 9434c3d98d5..c16fef3e059 100644 --- a/tests/components/test_webhook.py +++ b/tests/components/test_webhook.py @@ -22,7 +22,8 @@ async def test_unregistering_webhook(hass, mock_client): """Handle webhook.""" hooks.append(args) - hass.components.webhook.async_register(webhook_id, handle) + hass.components.webhook.async_register( + 'test', "Test hook", webhook_id, handle) resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) assert resp.status == 200 @@ -51,7 +52,7 @@ async def test_posting_webhook_nonexisting(hass, mock_client): async def test_posting_webhook_invalid_json(hass, mock_client): """Test posting to a nonexisting webhook.""" - hass.components.webhook.async_register('hello', None) + hass.components.webhook.async_register('test', "Test hook", 'hello', None) resp = await mock_client.post('/api/webhook/hello', data='not-json') assert resp.status == 200 @@ -65,7 +66,8 @@ async def test_posting_webhook_json(hass, mock_client): """Handle webhook.""" hooks.append((args[0], args[1], await args[2].text())) - hass.components.webhook.async_register(webhook_id, handle) + hass.components.webhook.async_register( + 'test', "Test hook", webhook_id, handle) resp = await mock_client.post('/api/webhook/{}'.format(webhook_id), json={ 'data': True @@ -86,7 +88,8 @@ async def test_posting_webhook_no_data(hass, mock_client): """Handle webhook.""" hooks.append(args) - hass.components.webhook.async_register(webhook_id, handle) + hass.components.webhook.async_register( + 'test', "Test hook", webhook_id, handle) resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) assert resp.status == 200 @@ -94,3 +97,28 @@ async def test_posting_webhook_no_data(hass, mock_client): assert hooks[0][0] is hass assert hooks[0][1] == webhook_id assert await hooks[0][2].text() == '' + + +async def test_listing_webhook(hass, hass_ws_client, hass_access_token): + """Test unregistering a webhook.""" + assert await async_setup_component(hass, 'webhook', {}) + client = await hass_ws_client(hass, hass_access_token) + + hass.components.webhook.async_register( + 'test', "Test hook", "my-id", None) + + await client.send_json({ + 'id': 5, + 'type': 'webhook/list', + }) + + msg = await client.receive_json() + assert msg['id'] == 5 + assert msg['success'] + assert msg['result'] == [ + { + 'webhook_id': 'my-id', + 'domain': 'test', + 'name': 'Test hook' + } + ] diff --git a/tests/components/twilio/test_init.py b/tests/components/twilio/test_init.py index c740783f4c0..3be211532ed 100644 --- a/tests/components/twilio/test_init.py +++ b/tests/components/twilio/test_init.py @@ -7,35 +7,36 @@ from homeassistant.core import callback from tests.common import MockDependency -@MockDependency('twilio', 'rest') -@MockDependency('twilio', 'twiml') async def test_config_flow_registers_webhook(hass, aiohttp_client): """Test setting up Twilio and sending webhook.""" - with patch('homeassistant.util.get_local_ip', return_value='example.com'): - result = await hass.config_entries.flow.async_init('twilio', context={ - 'source': 'user' + with MockDependency('twilio', 'rest'), MockDependency('twilio', 'twiml'): + with patch('homeassistant.util.get_local_ip', + return_value='example.com'): + result = await hass.config_entries.flow.async_init( + 'twilio', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + webhook_id = result['result'].data['webhook_id'] + + twilio_events = [] + + @callback + def handle_event(event): + """Handle Twilio event.""" + twilio_events.append(event) + + hass.bus.async_listen(twilio.RECEIVED_DATA, handle_event) + + client = await aiohttp_client(hass.http.app) + await client.post('/api/webhook/{}'.format(webhook_id), data={ + 'hello': 'twilio' }) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result - result = await hass.config_entries.flow.async_configure( - result['flow_id'], {}) - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - webhook_id = result['result'].data['webhook_id'] - - twilio_events = [] - - @callback - def handle_event(event): - """Handle Twilio event.""" - twilio_events.append(event) - - hass.bus.async_listen(twilio.RECEIVED_DATA, handle_event) - - client = await aiohttp_client(hass.http.app) - await client.post('/api/webhook/{}'.format(webhook_id), data={ - 'hello': 'twilio' - }) - - assert len(twilio_events) == 1 - assert twilio_events[0].data['webhook_id'] == webhook_id - assert twilio_events[0].data['hello'] == 'twilio' + assert len(twilio_events) == 1 + assert twilio_events[0].data['webhook_id'] == webhook_id + assert twilio_events[0].data['hello'] == 'twilio' From a901c594a9b4c491971ab9d122fcdbd7ad40ceca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 5 Nov 2018 09:28:02 +0100 Subject: [PATCH 023/238] Add Traccar device tracker (#18200) * Add Traccar device tracker. * Updated pytraccar to 0.1.1 * Adds default values for optional options. * Use dict[key] for options. * remove logging, duplicate by core --- .coveragerc | 1 + .../components/device_tracker/traccar.py | 90 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 94 insertions(+) create mode 100644 homeassistant/components/device_tracker/traccar.py diff --git a/.coveragerc b/.coveragerc index f5103867688..17fcb505fc0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -499,6 +499,7 @@ omit = homeassistant/components/device_tracker/tile.py homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tplink.py + homeassistant/components/device_tracker/traccar.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py homeassistant/components/downloader.py diff --git a/homeassistant/components/device_tracker/traccar.py b/homeassistant/components/device_tracker/traccar.py new file mode 100644 index 00000000000..982a572fd94 --- /dev/null +++ b/homeassistant/components/device_tracker/traccar.py @@ -0,0 +1,90 @@ +""" +Support for Traccar device tracking. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.traccar/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, + CONF_PASSWORD, CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import slugify + + +REQUIREMENTS = ['pytraccar==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_ADDRESS = 'address' +ATTR_CATEGORY = 'category' +ATTR_GEOFENCE = 'geofence' +ATTR_TRACKER = 'tracker' + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=8082): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, +}) + + +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Validate the configuration and return a Traccar scanner.""" + from pytraccar.api import API + + session = async_get_clientsession(hass, config[CONF_VERIFY_SSL]) + + api = API(hass.loop, session, config[CONF_USERNAME], config[CONF_PASSWORD], + config[CONF_HOST], config[CONF_PORT], config[CONF_SSL]) + scanner = TraccarScanner(api, hass, async_see) + return await scanner.async_init() + + +class TraccarScanner: + """Define an object to retrieve Traccar data.""" + + def __init__(self, api, hass, async_see): + """Initialize.""" + self._async_see = async_see + self._api = api + self._hass = hass + + async def async_init(self): + """Further initialize connection to Traccar.""" + await self._api.test_connection() + if self._api.authenticated: + await self._async_update() + async_track_time_interval(self._hass, + self._async_update, + DEFAULT_SCAN_INTERVAL) + + return self._api.authenticated + + async def _async_update(self, now=None): + """Update info from Traccar.""" + _LOGGER.debug('Updating device data.') + await self._api.get_device_info() + for devicename in self._api.device_info: + device = self._api.device_info[devicename] + device_attributes = { + ATTR_ADDRESS: device['address'], + ATTR_GEOFENCE: device['geofence'], + ATTR_CATEGORY: device['category'], + ATTR_TRACKER: 'traccar' + } + await self._async_see( + dev_id=slugify(device['device_id']), + gps=(device['latitude'], device['longitude']), + attributes=device_attributes) diff --git a/requirements_all.txt b/requirements_all.txt index 6bf7ff0c98e..1cde93097f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1245,6 +1245,9 @@ pytile==2.0.2 # homeassistant.components.climate.touchline pytouchline==0.7 +# homeassistant.components.device_tracker.traccar +pytraccar==0.1.2 + # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From 8ee0e0c6c6ae9736bbab2269ea6d0b4d6021e066 Mon Sep 17 00:00:00 2001 From: quthla Date: Mon, 5 Nov 2018 11:11:26 +0100 Subject: [PATCH 024/238] Turn off not cancellable scripts automatically HomeKit (#17793) --- .../components/homekit/type_switches.py | 29 ++++++ .../components/homekit/test_type_switches.py | 89 ++++++++++++++++--- 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 288da65a4af..75a9a31bee1 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -4,10 +4,12 @@ import logging from pyhap.const import ( CATEGORY_OUTLET, CATEGORY_SWITCH) +from homeassistant.components.script import ATTR_CAN_CANCEL from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, CONF_TYPE, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON) from homeassistant.core import split_entity_id +from homeassistant.helpers.event import call_later from . import TYPES from .accessories import HomeAccessory @@ -74,21 +76,48 @@ class Switch(HomeAccessory): self._domain = split_entity_id(self.entity_id)[0] self._flag_state = False + self.activate_only = self.is_activate( + self.hass.states.get(self.entity_id)) + serv_switch = self.add_preload_service(SERV_SWITCH) self.char_on = serv_switch.configure_char( CHAR_ON, value=False, setter_callback=self.set_state) + def is_activate(self, state): + """Check if entity is activate only.""" + can_cancel = state.attributes.get(ATTR_CAN_CANCEL) + if self._domain == 'script' and not can_cancel: + return True + return False + + def reset_switch(self, *args): + """Reset switch to emulate activate click.""" + _LOGGER.debug('%s: Reset switch to off', self.entity_id) + self.char_on.set_value(0) + def set_state(self, value): """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state to %s', self.entity_id, value) + if self.activate_only and value == 0: + _LOGGER.debug('%s: Ignoring turn_off call', self.entity_id) + return self._flag_state = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF self.call_service(self._domain, service, params) + if self.activate_only: + call_later(self.hass, 1, self.reset_switch) + def update_state(self, new_state): """Update switch state after state changed.""" + self.activate_only = self.is_activate(new_state) + if self.activate_only: + _LOGGER.debug('%s: Ignore state change, entity is activate only', + self.entity_id) + return + current_state = (new_state.state == STATE_ON) if not self._flag_state: _LOGGER.debug('%s: Set current state to %s', diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index d170647d492..c94929e560c 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -1,14 +1,18 @@ """Test different accessory types: Switches.""" +from datetime import timedelta + import pytest from homeassistant.components.homekit.const import ( ATTR_VALUE, TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_VALVE) from homeassistant.components.homekit.type_switches import ( Outlet, Switch, Valve) +from homeassistant.components.script import ATTR_CAN_CANCEL from homeassistant.const import ATTR_ENTITY_ID, CONF_TYPE, STATE_OFF, STATE_ON from homeassistant.core import split_entity_id +import homeassistant.util.dt as dt_util -from tests.common import async_mock_service +from tests.common import async_fire_time_changed, async_mock_service async def test_outlet_set_state(hass, hk_driver, events): @@ -54,18 +58,18 @@ async def test_outlet_set_state(hass, hk_driver, events): assert events[-1].data[ATTR_VALUE] is None -@pytest.mark.parametrize('entity_id', [ - 'automation.test', - 'input_boolean.test', - 'remote.test', - 'script.test', - 'switch.test', +@pytest.mark.parametrize('entity_id, attrs', [ + ('automation.test', {}), + ('input_boolean.test', {}), + ('remote.test', {}), + ('script.test', {ATTR_CAN_CANCEL: True}), + ('switch.test', {}), ]) -async def test_switch_set_state(hass, hk_driver, entity_id, events): +async def test_switch_set_state(hass, hk_driver, entity_id, attrs, events): """Test if accessory and HA are updated accordingly.""" domain = split_entity_id(entity_id)[0] - hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, None, attrs) await hass.async_block_till_done() acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) await hass.async_add_job(acc.run) @@ -74,13 +78,14 @@ async def test_switch_set_state(hass, hk_driver, entity_id, events): assert acc.aid == 2 assert acc.category == 8 # Switch + assert acc.activate_only is False assert acc.char_on.value is False - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, attrs) await hass.async_block_till_done() assert acc.char_on.value is True - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, attrs) await hass.async_block_till_done() assert acc.char_on.value is False @@ -172,3 +177,65 @@ async def test_valve_set_state(hass, hk_driver, events): assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None + + +@pytest.mark.parametrize('entity_id, attrs', [ + ('script.test', {}), + ('script.test', {ATTR_CAN_CANCEL: False}), +]) +async def test_reset_switch(hass, hk_driver, entity_id, attrs, events): + """Test if switch accessory is reset correctly.""" + domain = split_entity_id(entity_id)[0] + + hass.states.async_set(entity_id, None, attrs) + await hass.async_block_till_done() + acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.activate_only is True + assert acc.char_on.value is False + + call_turn_on = async_mock_service(hass, domain, 'turn_on') + call_turn_off = async_mock_service(hass, domain, 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, True) + await hass.async_block_till_done() + assert acc.char_on.value is True + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert acc.char_on.value is False + assert len(events) == 1 + assert not call_turn_off + + await hass.async_add_job(acc.char_on.client_update_value, False) + await hass.async_block_till_done() + assert acc.char_on.value is False + assert len(events) == 1 + + +async def test_reset_switch_reload(hass, hk_driver, events): + """Test reset switch after script reload.""" + entity_id = 'script.test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.activate_only is True + + hass.states.async_set(entity_id, None, {ATTR_CAN_CANCEL: True}) + await hass.async_block_till_done() + assert acc.activate_only is False + + hass.states.async_set(entity_id, None, {ATTR_CAN_CANCEL: False}) + await hass.async_block_till_done() + assert acc.activate_only is True From 8de79ed57c59f6c3dcd8659ef120e8581147fe8d Mon Sep 17 00:00:00 2001 From: Marcel Hoppe Date: Mon, 5 Nov 2018 13:14:22 +0100 Subject: [PATCH 025/238] add service to reconnect the bot (#18142) --- homeassistant/components/hangouts/__init__.py | 8 +++++++- homeassistant/components/hangouts/const.py | 1 + homeassistant/components/hangouts/hangouts_bot.py | 5 +++++ homeassistant/components/hangouts/services.yaml | 5 ++++- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 5d8a167d2d9..01d81cc466c 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -22,7 +22,7 @@ from .const import ( SERVICE_UPDATE, CONF_SENTENCES, CONF_MATCHERS, CONF_ERROR_SUPPRESSED_CONVERSATIONS, INTENT_SCHEMA, TARGETS_SCHEMA, CONF_DEFAULT_CONVERSATIONS, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, - INTENT_HELP) + INTENT_HELP, SERVICE_RECONNECT) # We need an import from .config_flow, without it .config_flow is never loaded. from .config_flow import HangoutsFlowHandler # noqa: F401 @@ -130,6 +130,12 @@ async def async_setup_entry(hass, config): async_handle_update_users_and_conversations, schema=vol.Schema({})) + hass.services.async_register(DOMAIN, + SERVICE_RECONNECT, + bot. + async_handle_reconnect, + schema=vol.Schema({})) + intent.async_register(hass, HelpIntent(hass)) return True diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py index 5a527fae260..cf5374c317e 100644 --- a/homeassistant/components/hangouts/const.py +++ b/homeassistant/components/hangouts/const.py @@ -39,6 +39,7 @@ CONF_CONVERSATION_NAME = 'name' SERVICE_SEND_MESSAGE = 'send_message' SERVICE_UPDATE = 'update' +SERVICE_RECONNECT = 'reconnect' TARGETS_SCHEMA = vol.All( diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index ed041a30ce6..748079452d8 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -313,6 +313,11 @@ class HangoutsBot: """Handle the update_users_and_conversations service.""" await self._async_list_conversations() + async def async_handle_reconnect(self, _=None): + """Handle the reconnect service.""" + await self.async_disconnect() + await self.async_connect() + def get_intents(self, conv_id): """Return the intents for a specific conversation.""" return self._conversation_intents.get(conv_id) diff --git a/homeassistant/components/hangouts/services.yaml b/homeassistant/components/hangouts/services.yaml index d07f1d65688..26a7193493b 100644 --- a/homeassistant/components/hangouts/services.yaml +++ b/homeassistant/components/hangouts/services.yaml @@ -1,5 +1,5 @@ update: - description: Updates the list of users and conversations. + description: Updates the list of conversations. send_message: description: Send a notification to a specific target. @@ -13,3 +13,6 @@ send_message: data: description: Other options ['image_file' / 'image_url'] example: '{ "image_file": "file" } or { "image_url": "url" }' + +reconnect: + description: Reconnect the bot. \ No newline at end of file From bf54582d762c6e9f2d56eceaebb902eff975c6ea Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Nov 2018 13:21:03 +0100 Subject: [PATCH 026/238] Cloud conf (#18216) * Add original config to entityfilter * Add alexa/google config to cloud status call * Lint --- homeassistant/components/cloud/__init__.py | 4 +-- homeassistant/components/cloud/http_api.py | 6 ++++ homeassistant/helpers/entityfilter.py | 28 +++++++++------ tests/components/cloud/test_http_api.py | 40 +++++++++++++++++++--- tests/helpers/test_entityfilter.py | 16 +++++++-- 5 files changed, 74 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index ba2d41a9feb..bc486eb7ead 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -122,7 +122,7 @@ class Cloud: self.hass = hass self.mode = mode self.alexa_config = alexa - self._google_actions = google_actions + self.google_actions_user_conf = google_actions self._gactions_config = None self._prefs = None self.id_token = None @@ -180,7 +180,7 @@ class Cloud: def gactions_config(self): """Return the Google Assistant config.""" if self._gactions_config is None: - conf = self._google_actions + conf = self.google_actions_user_conf def should_expose(entity): """If an entity should be exposed.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 0df4a39406e..cb62d773dfd 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -11,6 +11,8 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components import websocket_api +from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.google_assistant import smart_home as google_sh from . import auth_api from .const import DOMAIN, REQUEST_TIMEOUT @@ -307,5 +309,9 @@ def _account_data(cloud): 'email': claims['email'], 'cloud': cloud.iot.state, 'google_enabled': cloud.google_enabled, + 'google_entities': cloud.google_actions_user_conf['filter'].config, + 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), 'alexa_enabled': cloud.alexa_enabled, + 'alexa_entities': cloud.alexa_config.should_expose.config, + 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), } diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index c9554488aa7..141fc912275 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -10,6 +10,18 @@ CONF_INCLUDE_ENTITIES = 'include_entities' CONF_EXCLUDE_DOMAINS = 'exclude_domains' CONF_EXCLUDE_ENTITIES = 'exclude_entities' + +def _convert_filter(config): + filt = generate_filter( + config[CONF_INCLUDE_DOMAINS], + config[CONF_INCLUDE_ENTITIES], + config[CONF_EXCLUDE_DOMAINS], + config[CONF_EXCLUDE_ENTITIES], + ) + filt.config = config + return filt + + FILTER_SCHEMA = vol.All( vol.Schema({ vol.Optional(CONF_EXCLUDE_DOMAINS, default=[]): @@ -18,13 +30,7 @@ FILTER_SCHEMA = vol.All( 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], - )) + }), _convert_filter) def generate_filter(include_domains, include_entities, @@ -64,8 +70,8 @@ def generate_filter(include_domains, include_entities, # Case 4 - both includes and excludes specified # Case 4a - include domain specified - # - if domain is included, and entity not excluded, pass - # - if domain is not included, and entity not included, fail + # - if domain is included, pass if entity not excluded + # - if domain is not included, pass if entity is included # note: if both include and exclude domains specified, # the exclude domains are ignored if include_d: @@ -79,8 +85,8 @@ def generate_filter(include_domains, include_entities, return entity_filter_4a # Case 4b - exclude domain specified - # - if domain is excluded, and entity not included, fail - # - if domain is not excluded, and entity not excluded, pass + # - if domain is excluded, pass if entity is included + # - if domain is not excluded, pass if entity not excluded if exclude_d: def entity_filter_4b(entity_id): """Return filter function for case 4b.""" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index e27760bd6ed..a8128c8d3e0 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -35,6 +35,16 @@ def setup_api(hass): 'relayer': 'relayer', 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, 'subscription_info_url': SUBSCRIPTION_INFO_URL, + 'google_actions': { + 'filter': { + 'include_domains': 'light' + } + }, + 'alexa': { + 'filter': { + 'include_entities': ['light.kitchen', 'switch.ac'] + } + } }) return mock_cloud_prefs(hass) @@ -325,17 +335,37 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): }, 'test') hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED client = await hass_ws_client(hass) - await client.send_json({ - 'id': 5, - 'type': 'cloud/status' - }) - response = await client.receive_json() + + with patch.dict( + 'homeassistant.components.google_assistant.smart_home.' + 'DOMAIN_TO_GOOGLE_TYPES', {'light': None}, clear=True + ), patch.dict('homeassistant.components.alexa.smart_home.ENTITY_ADAPTERS', + {'switch': None}, clear=True): + await client.send_json({ + 'id': 5, + 'type': 'cloud/status' + }) + response = await client.receive_json() assert response['result'] == { 'logged_in': True, 'email': 'hello@home-assistant.io', 'cloud': 'connected', 'alexa_enabled': True, + 'alexa_entities': { + 'include_domains': [], + 'include_entities': ['light.kitchen', 'switch.ac'], + 'exclude_domains': [], + 'exclude_entities': [], + }, + 'alexa_domains': ['switch'], 'google_enabled': True, + 'google_entities': { + 'include_domains': ['light'], + 'include_entities': [], + 'exclude_domains': [], + 'exclude_entities': [], + }, + 'google_domains': ['light'], } diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 944224a34d1..13e5bc1d273 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -1,5 +1,5 @@ """The tests for the EntityFilter component.""" -from homeassistant.helpers.entityfilter import generate_filter +from homeassistant.helpers.entityfilter import generate_filter, FILTER_SCHEMA def test_no_filters_case_1(): @@ -78,7 +78,7 @@ def test_exclude_domain_case4b(): assert testfilter("sun.sun") is True -def testno_domain_case4c(): +def test_no_domain_case4c(): """Test case 4c - include and exclude specified, with no domains.""" incl_dom = {} incl_ent = {'binary_sensor.working'} @@ -93,3 +93,15 @@ def testno_domain_case4c(): assert testfilter("binary_sensor.working") assert testfilter("binary_sensor.another") is False assert testfilter("sun.sun") is False + + +def test_filter_schema(): + """Test filter schema.""" + conf = { + 'include_domains': ['light'], + 'include_entities': ['switch.kitchen'], + 'exclude_domains': ['cover'], + 'exclude_entities': ['light.kitchen'] + } + filt = FILTER_SCHEMA(conf) + assert filt.config == conf From 36524e9d3f9c4cd99dd455b26f117f4c20aa2644 Mon Sep 17 00:00:00 2001 From: Adam Belebczuk Date: Mon, 5 Nov 2018 07:23:46 -0500 Subject: [PATCH 027/238] Bump version of pywemo to 0.4.29 (#18217) --- homeassistant/components/wemo.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 55e45bc210a..ab2094ba9d7 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.28'] +REQUIREMENTS = ['pywemo==0.4.29'] DOMAIN = 'wemo' diff --git a/requirements_all.txt b/requirements_all.txt index 1cde93097f6..f59968411ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1282,7 +1282,7 @@ pyvlx==0.1.3 pywebpush==1.6.0 # homeassistant.components.wemo -pywemo==0.4.28 +pywemo==0.4.29 # homeassistant.components.camera.xeoma pyxeoma==1.4.0 From f9f53fd2782399c2b21b7f843a27de17a39edfcd Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Mon, 5 Nov 2018 16:10:30 +0100 Subject: [PATCH 028/238] Removes melissa sensors (they should be state attributes as implemented in #18201) (#18214) --- homeassistant/components/melissa.py | 2 - homeassistant/components/sensor/melissa.py | 101 ------------------- tests/components/sensor/test_melissa.py | 112 --------------------- 3 files changed, 215 deletions(-) delete mode 100644 homeassistant/components/sensor/melissa.py delete mode 100644 tests/components/sensor/test_melissa.py diff --git a/homeassistant/components/melissa.py b/homeassistant/components/melissa.py index da2ec49d11f..638d8c55bd5 100644 --- a/homeassistant/components/melissa.py +++ b/homeassistant/components/melissa.py @@ -39,8 +39,6 @@ async def async_setup(hass, config): await api.async_connect() hass.data[DATA_MELISSA] = api - hass.async_create_task( - async_load_platform(hass, 'sensor', DOMAIN, {}, config)) hass.async_create_task( async_load_platform(hass, 'climate', DOMAIN, {}, config)) return True diff --git a/homeassistant/components/sensor/melissa.py b/homeassistant/components/sensor/melissa.py deleted file mode 100644 index c11c5c76740..00000000000 --- a/homeassistant/components/sensor/melissa.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Support for Melissa climate Sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.melissa/ -""" -import logging - -from homeassistant.components.melissa import DATA_MELISSA -from homeassistant.const import TEMP_CELSIUS -from homeassistant.helpers.entity import Entity - -DEPENDENCIES = ['melissa'] - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the melissa sensor platform.""" - sensors = [] - api = hass.data[DATA_MELISSA] - - devices = (await api.async_fetch_devices()).values() - - for device in devices: - if device['type'] == 'melissa': - sensors.append(MelissaTemperatureSensor(device, api)) - sensors.append(MelissaHumiditySensor(device, api)) - async_add_entities(sensors) - - -class MelissaSensor(Entity): - """Representation of a Melissa Sensor.""" - - _type = 'generic' - - def __init__(self, device, api): - """Initialize the sensor.""" - self._api = api - self._state = None - self._name = '{0} {1}'.format( - device['name'], - self._type - ) - self._serial = device['serial_number'] - self._data = device['controller_log'] - - @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 - - async def async_update(self): - """Fetch status from melissa.""" - self._data = await self._api.async_status(cached=True) - - -class MelissaTemperatureSensor(MelissaSensor): - """Representation of a Melissa temperature Sensor.""" - - _type = 'temperature' - _unit = TEMP_CELSIUS - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - async def async_update(self): - """Fetch new state data for the sensor.""" - await super().async_update() - try: - self._state = self._data[self._serial]['temp'] - except KeyError: - _LOGGER.warning("Unable to get temperature for %s", self.entity_id) - - -class MelissaHumiditySensor(MelissaSensor): - """Representation of a Melissa humidity Sensor.""" - - _type = 'humidity' - _unit = '%' - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - async def async_update(self): - """Fetch new state data for the sensor.""" - await super().async_update() - try: - self._state = self._data[self._serial]['humidity'] - except KeyError: - _LOGGER.warning("Unable to get humidity for %s", self.entity_id) diff --git a/tests/components/sensor/test_melissa.py b/tests/components/sensor/test_melissa.py deleted file mode 100644 index 024e2e564eb..00000000000 --- a/tests/components/sensor/test_melissa.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Test for Melissa climate component.""" -import json -from unittest.mock import Mock, patch - -from homeassistant.components.sensor.melissa import MelissaTemperatureSensor, \ - MelissaHumiditySensor - -from tests.common import load_fixture, mock_coro_func - -from homeassistant.components.melissa import DATA_MELISSA -from homeassistant.components.sensor import melissa -from homeassistant.const import TEMP_CELSIUS - - -_SERIAL = "12345678" - - -def melissa_mock(): - """Use this to mock the melissa api.""" - api = Mock() - api.async_fetch_devices = mock_coro_func( - return_value=json.loads(load_fixture('melissa_fetch_devices.json'))) - api.async_status = mock_coro_func(return_value=json.loads(load_fixture( - 'melissa_status.json' - ))) - - api.TEMP = 'temp' - api.HUMIDITY = 'humidity' - return api - - -async def test_setup_platform(hass): - """Test setup_platform.""" - with patch('homeassistant.components.melissa'): - hass.data[DATA_MELISSA] = melissa_mock() - - config = {} - async_add_entities = mock_coro_func() - discovery_info = {} - - await melissa.async_setup_platform( - hass, config, async_add_entities, discovery_info) - - -async def test_name(hass): - """Test name property.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - - assert temp.name == '{0} {1}'.format( - device['name'], - temp._type - ) - assert hum.name == '{0} {1}'.format( - device['name'], - hum._type - ) - - -async def test_state(hass): - """Test state property.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - status = (await mocked_melissa.async_status())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - await temp.async_update() - assert temp.state == status[mocked_melissa.TEMP] - await hum.async_update() - assert hum.state == status[mocked_melissa.HUMIDITY] - - -async def test_unit_of_measurement(hass): - """Test unit of measurement property.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - assert temp.unit_of_measurement == TEMP_CELSIUS - assert hum.unit_of_measurement == '%' - - -async def test_update(hass): - """Test for update.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - await temp.async_update() - assert temp.state == 27.4 - await hum.async_update() - assert hum.state == 18.7 - - -async def test_update_keyerror(hass): - """Test for faulty update.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - mocked_melissa.async_status = mock_coro_func(return_value={}) - await temp.async_update() - assert temp.state is None - await hum.async_update() - assert hum.state is None From 81fa74e5cae0c904284a79dcb328d3eb7acefff8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Nov 2018 16:14:34 +0100 Subject: [PATCH 029/238] Remove unrelated scripts (#18219) * Remove influxDB scripts * Remove ancient db migrator * Update requirements --- homeassistant/scripts/db_migrator.py | 191 -------------- homeassistant/scripts/influxdb_import.py | 281 --------------------- homeassistant/scripts/influxdb_migrator.py | 193 -------------- requirements_all.txt | 1 - requirements_test_all.txt | 1 - 5 files changed, 667 deletions(-) delete mode 100644 homeassistant/scripts/db_migrator.py delete mode 100644 homeassistant/scripts/influxdb_import.py delete mode 100644 homeassistant/scripts/influxdb_migrator.py diff --git a/homeassistant/scripts/db_migrator.py b/homeassistant/scripts/db_migrator.py deleted file mode 100644 index 419f1138bf0..00000000000 --- a/homeassistant/scripts/db_migrator.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Script to convert an old-format home-assistant.db to a new format one.""" - -import argparse -import os.path -import sqlite3 -import sys - -from datetime import datetime -from typing import Optional, List - -import homeassistant.config as config_util -import homeassistant.util.dt as dt_util -# pylint: disable=unused-import -from homeassistant.components.recorder import REQUIREMENTS # NOQA - - -def ts_to_dt(timestamp: Optional[float]) -> Optional[datetime]: - """Turn a datetime into an integer for in the DB.""" - if timestamp is None: - return None - return dt_util.utc_from_timestamp(timestamp) - - -# Based on code at -# http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str = '', - suffix: str = '', decimals: int = 2, - bar_length: int = 68) -> None: - """Print progress bar. - - Call in a loop to create terminal progress bar - @params: - iteration - Required : current iteration (Int) - total - Required : total iterations (Int) - prefix - Optional : prefix string (Str) - suffix - Optional : suffix string (Str) - decimals - Optional : number of decimals in percent complete (Int) - barLength - Optional : character length of bar (Int) - """ - filled_length = int(round(bar_length * iteration / float(total))) - percents = round(100.00 * (iteration / float(total)), decimals) - line = '#' * filled_length + '-' * (bar_length - filled_length) - sys.stdout.write('%s [%s] %s%s %s\r' % (prefix, line, - percents, '%', suffix)) - sys.stdout.flush() - if iteration == total: - print("\n") - - -def run(script_args: List) -> int: - """Run the actual script.""" - # pylint: disable=invalid-name - from sqlalchemy import create_engine - from sqlalchemy.orm import sessionmaker - from homeassistant.components.recorder import models - - parser = argparse.ArgumentParser( - description="Migrate legacy DB to SQLAlchemy format.") - parser.add_argument( - '-c', '--config', - metavar='path_to_config_dir', - default=config_util.get_default_config_dir(), - help="Directory that contains the Home Assistant configuration") - parser.add_argument( - '-a', '--append', - action='store_true', - default=False, - help="Append to existing new format SQLite database") - parser.add_argument( - '--uri', - type=str, - help="Connect to URI and import (implies --append)" - "eg: mysql://localhost/homeassistant") - parser.add_argument( - '--script', - choices=['db_migrator']) - - args = parser.parse_args() - - config_dir = os.path.join(os.getcwd(), args.config) # type: str - - # Test if configuration directory exists - if not os.path.isdir(config_dir): - if config_dir != config_util.get_default_config_dir(): - print(('Fatal Error: Specified configuration directory does ' - 'not exist {} ').format(config_dir)) - return 1 - - src_db = '{}/home-assistant.db'.format(config_dir) - dst_db = '{}/home-assistant_v2.db'.format(config_dir) - - if not os.path.exists(src_db): - print("Fatal Error: Old format database '{}' does not exist".format( - src_db)) - return 1 - if not args.uri and (os.path.exists(dst_db) and not args.append): - print("Fatal Error: New format database '{}' exists already - " - "Remove it or use --append".format(dst_db)) - print("Note: --append must maintain an ID mapping and is much slower" - "and requires sufficient memory to track all event IDs") - return 1 - - conn = sqlite3.connect(src_db) - uri = args.uri or "sqlite:///{}".format(dst_db) - - engine = create_engine(uri, echo=False) - models.Base.metadata.create_all(engine) - session_factory = sessionmaker(bind=engine) - session = session_factory() - - append = args.append or args.uri - - c = conn.cursor() - c.execute("SELECT count(*) FROM recorder_runs") - num_rows = c.fetchone()[0] - print("Converting {} recorder_runs".format(num_rows)) - c.close() - - c = conn.cursor() - n = 0 - for row in c.execute("SELECT * FROM recorder_runs"): # type: ignore - n += 1 - session.add(models.RecorderRuns( - start=ts_to_dt(row[1]), - end=ts_to_dt(row[2]), - closed_incorrect=row[3], - created=ts_to_dt(row[4]) - )) - if n % 1000 == 0: - session.commit() - print_progress(n, num_rows) - print_progress(n, num_rows) - session.commit() - c.close() - - c = conn.cursor() - c.execute("SELECT count(*) FROM events") - num_rows = c.fetchone()[0] - print("Converting {} events".format(num_rows)) - c.close() - - id_mapping = {} - - c = conn.cursor() - n = 0 - for row in c.execute("SELECT * FROM events"): # type: ignore - n += 1 - o = models.Events( - event_type=row[1], - event_data=row[2], - origin=row[3], - created=ts_to_dt(row[4]), - time_fired=ts_to_dt(row[5]), - ) - session.add(o) - if append: - session.flush() - id_mapping[row[0]] = o.event_id - if n % 1000 == 0: - session.commit() - print_progress(n, num_rows) - print_progress(n, num_rows) - session.commit() - c.close() - - c = conn.cursor() - c.execute("SELECT count(*) FROM states") - num_rows = c.fetchone()[0] - print("Converting {} states".format(num_rows)) - c.close() - - c = conn.cursor() - n = 0 - for row in c.execute("SELECT * FROM states"): # type: ignore - n += 1 - session.add(models.States( - entity_id=row[1], - state=row[2], - attributes=row[3], - last_changed=ts_to_dt(row[4]), - last_updated=ts_to_dt(row[5]), - event_id=id_mapping.get(row[6], row[6]), - domain=row[7] - )) - if n % 1000 == 0: - session.commit() - print_progress(n, num_rows) - print_progress(n, num_rows) - session.commit() - c.close() - return 0 diff --git a/homeassistant/scripts/influxdb_import.py b/homeassistant/scripts/influxdb_import.py deleted file mode 100644 index a6dd90920c3..00000000000 --- a/homeassistant/scripts/influxdb_import.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Script to import recorded data into an Influx database.""" -import argparse -import json -import os -import sys -from typing import List - -import homeassistant.config as config_util - - -def run(script_args: List) -> int: - """Run the actual script.""" - from sqlalchemy import create_engine - from sqlalchemy import func - from sqlalchemy.orm import sessionmaker - from influxdb import InfluxDBClient - from homeassistant.components.recorder import models - from homeassistant.helpers import state as state_helper - from homeassistant.core import State - from homeassistant.core import HomeAssistantError - - parser = argparse.ArgumentParser( - description="import data to influxDB.") - parser.add_argument( - '-c', '--config', - metavar='path_to_config_dir', - default=config_util.get_default_config_dir(), - help="Directory that contains the Home Assistant configuration") - parser.add_argument( - '--uri', - type=str, - help="Connect to URI and import (if other than default sqlite) " - "eg: mysql://localhost/homeassistant") - parser.add_argument( - '-d', '--dbname', - metavar='dbname', - required=True, - help="InfluxDB database name") - parser.add_argument( - '-H', '--host', - metavar='host', - default='127.0.0.1', - help="InfluxDB host address") - parser.add_argument( - '-P', '--port', - metavar='port', - default=8086, - help="InfluxDB host port") - parser.add_argument( - '-u', '--username', - metavar='username', - default='root', - help="InfluxDB username") - parser.add_argument( - '-p', '--password', - metavar='password', - default='root', - help="InfluxDB password") - parser.add_argument( - '-s', '--step', - metavar='step', - default=1000, - help="How many points to import at the same time") - parser.add_argument( - '-t', '--tags', - metavar='tags', - default="", - help="Comma separated list of tags (key:value) for all points") - parser.add_argument( - '-D', '--default-measurement', - metavar='default_measurement', - default="", - help="Store all your points in the same measurement") - parser.add_argument( - '-o', '--override-measurement', - metavar='override_measurement', - default="", - help="Store all your points in the same measurement") - parser.add_argument( - '-e', '--exclude_entities', - metavar='exclude_entities', - default="", - help="Comma separated list of excluded entities") - parser.add_argument( - '-E', '--exclude_domains', - metavar='exclude_domains', - default="", - help="Comma separated list of excluded domains") - parser.add_argument( - "-S", "--simulate", - default=False, - action="store_true", - help=("Do not write points but simulate preprocessing and print " - "statistics")) - parser.add_argument( - '--script', - choices=['influxdb_import']) - - args = parser.parse_args() - simulate = args.simulate - - client = None - if not simulate: - client = InfluxDBClient( - args.host, args.port, args.username, args.password) - client.switch_database(args.dbname) - - config_dir = os.path.join(os.getcwd(), args.config) # type: str - - # Test if configuration directory exists - if not os.path.isdir(config_dir): - if config_dir != config_util.get_default_config_dir(): - print(('Fatal Error: Specified configuration directory does ' - 'not exist {} ').format(config_dir)) - return 1 - - src_db = '{}/home-assistant_v2.db'.format(config_dir) - - if not os.path.exists(src_db) and not args.uri: - print("Fatal Error: Database '{}' does not exist " - "and no URI given".format(src_db)) - return 1 - - uri = args.uri or 'sqlite:///{}'.format(src_db) - engine = create_engine(uri, echo=False) - session_factory = sessionmaker(bind=engine) - session = session_factory() - step = int(args.step) - step_start = 0 - - tags = {} - if args.tags: - tags.update(dict(elem.split(':') for elem in args.tags.split(','))) - excl_entities = args.exclude_entities.split(',') - excl_domains = args.exclude_domains.split(',') - override_measurement = args.override_measurement - default_measurement = args.default_measurement - - # pylint: disable=assignment-from-no-return - query = session.query(func.count(models.Events.event_type)).filter( - models.Events.event_type == 'state_changed') - - total_events = query.scalar() - prefix_format = '{} of {}' - - points = [] - invalid_points = [] - count = 0 - from collections import defaultdict - entities = defaultdict(int) - print_progress(0, total_events, prefix_format.format(0, total_events)) - - while True: - - step_stop = step_start + step - if step_start > total_events: - print_progress(total_events, total_events, prefix_format.format( - total_events, total_events)) - break - query = session.query(models.Events).filter( - models.Events.event_type == 'state_changed').order_by( - models.Events.time_fired).slice(step_start, step_stop) - - for event in query: - event_data = json.loads(event.event_data) - - if not ('entity_id' in event_data) or ( - excl_entities and event_data[ - 'entity_id'] in excl_entities) or ( - excl_domains and event_data[ - 'entity_id'].split('.')[0] in excl_domains): - session.expunge(event) - continue - - try: - state = State.from_dict(event_data.get('new_state')) - except HomeAssistantError: - invalid_points.append(event_data) - - if not state: - invalid_points.append(event_data) - continue - - try: - _state = float(state_helper.state_as_number(state)) - _state_key = 'value' - except ValueError: - _state = state.state - _state_key = 'state' - - if override_measurement: - measurement = override_measurement - else: - measurement = state.attributes.get('unit_of_measurement') - if measurement in (None, ''): - if default_measurement: - measurement = default_measurement - else: - measurement = state.entity_id - - point = { - 'measurement': measurement, - 'tags': { - 'domain': state.domain, - 'entity_id': state.object_id, - }, - 'time': event.time_fired, - 'fields': { - _state_key: _state, - } - } - - for key, value in state.attributes.items(): - if key != 'unit_of_measurement': - # If the key is already in fields - if key in point['fields']: - key = key + '_' - # Prevent column data errors in influxDB. - # For each value we try to cast it as float - # But if we can not do it we store the value - # as string add "_str" postfix to the field key - try: - point['fields'][key] = float(value) - except (ValueError, TypeError): - new_key = '{}_str'.format(key) - point['fields'][new_key] = str(value) - - entities[state.entity_id] += 1 - point['tags'].update(tags) - points.append(point) - session.expunge(event) - - if points: - if not simulate: - client.write_points(points) - count += len(points) - # This prevents the progress bar from going over 100% when - # the last step happens - print_progress((step_start + len( - points)), total_events, prefix_format.format( - step_start, total_events)) - else: - print_progress( - (step_start + step), total_events, prefix_format.format( - step_start, total_events)) - - points = [] - step_start += step - - print("\nStatistics:") - print("\n".join(["{:6}: {}".format(v, k) for k, v - in sorted(entities.items(), key=lambda x: x[1])])) - print("\nInvalid Points: {}".format(len(invalid_points))) - print("\nImport finished: {} points written".format(count)) - return 0 - - -# Based on code at -# http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str = '', - suffix: str = '', decimals: int = 2, - bar_length: int = 68) -> None: - """Print progress bar. - - Call in a loop to create terminal progress bar - @params: - iteration - Required : current iteration (Int) - total - Required : total iterations (Int) - prefix - Optional : prefix string (Str) - suffix - Optional : suffix string (Str) - decimals - Optional : number of decimals in percent complete (Int) - barLength - Optional : character length of bar (Int) - """ - filled_length = int(round(bar_length * iteration / float(total))) - percents = round(100.00 * (iteration / float(total)), decimals) - line = '#' * filled_length + '-' * (bar_length - filled_length) - sys.stdout.write('%s [%s] %s%s %s\r' % (prefix, line, - percents, '%', suffix)) - sys.stdout.flush() - if iteration == total: - print('\n') diff --git a/homeassistant/scripts/influxdb_migrator.py b/homeassistant/scripts/influxdb_migrator.py deleted file mode 100644 index 04d54cd3fa8..00000000000 --- a/homeassistant/scripts/influxdb_migrator.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Script to convert an old-structure influxdb to a new one.""" - -import argparse -import sys -from typing import List - - -# Based on code at -# http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str = '', - suffix: str = '', decimals: int = 2, - bar_length: int = 68) -> None: - """Print progress bar. - - Call in a loop to create terminal progress bar - @params: - iteration - Required : current iteration (Int) - total - Required : total iterations (Int) - prefix - Optional : prefix string (Str) - suffix - Optional : suffix string (Str) - decimals - Optional : number of decimals in percent complete (Int) - barLength - Optional : character length of bar (Int) - """ - filled_length = int(round(bar_length * iteration / float(total))) - percents = round(100.00 * (iteration / float(total)), decimals) - line = '#' * filled_length + '-' * (bar_length - filled_length) - sys.stdout.write('%s [%s] %s%s %s\r' % (prefix, line, - percents, '%', suffix)) - sys.stdout.flush() - if iteration == total: - print("\n") - - -def run(script_args: List) -> int: - """Run the actual script.""" - from influxdb import InfluxDBClient - - parser = argparse.ArgumentParser( - description="Migrate legacy influxDB.") - parser.add_argument( - '-d', '--dbname', - metavar='dbname', - required=True, - help="InfluxDB database name") - parser.add_argument( - '-H', '--host', - metavar='host', - default='127.0.0.1', - help="InfluxDB host address") - parser.add_argument( - '-P', '--port', - metavar='port', - default=8086, - help="InfluxDB host port") - parser.add_argument( - '-u', '--username', - metavar='username', - default='root', - help="InfluxDB username") - parser.add_argument( - '-p', '--password', - metavar='password', - default='root', - help="InfluxDB password") - parser.add_argument( - '-s', '--step', - metavar='step', - default=1000, - help="How many points to migrate at the same time") - parser.add_argument( - '-o', '--override-measurement', - metavar='override_measurement', - default="", - help="Store all your points in the same measurement") - parser.add_argument( - '-D', '--delete', - action='store_true', - default=False, - help="Delete old database") - parser.add_argument( - '--script', - choices=['influxdb_migrator']) - - args = parser.parse_args() - - # Get client for old DB - client = InfluxDBClient(args.host, args.port, - args.username, args.password) - client.switch_database(args.dbname) - # Get DB list - db_list = [db['name'] for db in client.get_list_database()] - # Get measurements of the old DB - res = client.query('SHOW MEASUREMENTS') - measurements = [measurement['name'] for measurement in res.get_points()] - nb_measurements = len(measurements) - # Move data - # Get old DB name - old_dbname = "{}__old".format(args.dbname) - # Create old DB if needed - if old_dbname not in db_list: - client.create_database(old_dbname) - # Copy data to the old DB - print("Cloning from {} to {}".format(args.dbname, old_dbname)) - for index, measurement in enumerate(measurements): - client.query('''SELECT * INTO {}..:MEASUREMENT FROM ''' - '"{}" GROUP BY *'.format(old_dbname, measurement)) - # Print progress - print_progress(index + 1, nb_measurements) - - # Delete the database - client.drop_database(args.dbname) - # Create new DB if needed - client.create_database(args.dbname) - client.switch_database(old_dbname) - # Get client for new DB - new_client = InfluxDBClient(args.host, args.port, args.username, - args.password, args.dbname) - # Counter of points without time - point_wt_time = 0 - - print("Migrating from {} to {}".format(old_dbname, args.dbname)) - # Walk into measurement - for index, measurement in enumerate(measurements): - - # Get tag list - res = client.query('''SHOW TAG KEYS FROM "{}"'''.format(measurement)) - tags = [v['tagKey'] for v in res.get_points()] - # Get field list - res = client.query('''SHOW FIELD KEYS FROM "{}"'''.format(measurement)) - fields = [v['fieldKey'] for v in res.get_points()] - # Get points, convert and send points to the new DB - offset = 0 - while True: - nb_points = 0 - # Prepare new points - new_points = [] - # Get points - res = client.query('SELECT * FROM "{}" LIMIT {} OFFSET ' - '{}'.format(measurement, args.step, offset)) - for point in res.get_points(): - new_point = {"tags": {}, - "fields": {}, - "time": None} - if args.override_measurement: - new_point["measurement"] = args.override_measurement - else: - new_point["measurement"] = measurement - # Check time - if point["time"] is None: - # Point without time - point_wt_time += 1 - print("Can not convert point without time") - continue - # Convert all fields - for field in fields: - try: - new_point["fields"][field] = float(point[field]) - except (ValueError, TypeError): - if field == "value": - new_key = "state" - else: - new_key = "{}_str".format(field) - new_point["fields"][new_key] = str(point[field]) - # Add tags - for tag in tags: - new_point["tags"][tag] = point[tag] - # Set time - new_point["time"] = point["time"] - # Add new point to the new list - new_points.append(new_point) - # Count nb points - nb_points += 1 - - # Send to the new db - try: - new_client.write_points(new_points) - except Exception as exp: - raise exp - - # If there is no points - if nb_points == 0: - # print("Measurement {} migrated".format(measurement)) - break - else: - # Increment offset - offset += args.step - # Print progress - print_progress(index + 1, nb_measurements) - - # Delete database if needed - if args.delete: - print("Dropping {}".format(old_dbname)) - client.drop_database(old_dbname) diff --git a/requirements_all.txt b/requirements_all.txt index f59968411ce..1edd30b6735 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1426,7 +1426,6 @@ spotcrime==1.0.3 spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.recorder -# homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql sqlalchemy==1.2.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7209e3b6b2e..8246079fe26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,6 @@ smhi-pkg==1.0.5 somecomfort==0.5.2 # homeassistant.components.recorder -# homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql sqlalchemy==1.2.13 From 3d4ff747612b348c74ee9101dd19bafe8dedcc15 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Mon, 5 Nov 2018 08:19:03 -0700 Subject: [PATCH 030/238] Add available property to DirecTV (#18168) * Enhancements for DirecTV media player Following enhancements have been made: 1. Added debug logging 2. Added ability to change channel using select_source service of the remote platform. 3. State will now show paused if a recorded program is paused, for live TV playing will always be returned. 4. Added the following attributes: a. media_position: current position of the media (in seconds) b. media_position_updated_at: timestamp when media_position was updated. c. source: current source (channel). d. media_isbeingrecorded: if current media is being recorded or not. e. media_rating: TV/Movie rating of the media f. media_recorded: if current media is recorded or live TV g. media_starttime: Timestamp media was aired Reordered properties to follow same order as how they are in __init__.py of remote platform. * Fixed error and cleaned up few items Fixed an issue when determining if a program is recorded or not. Cleaned up some coding. * Fix issue in checking if DTV device is already configured If a DTV device was configured before, then discovery would add this device again seperately if the name specified in the configuration is different from the name on the DTV. This issue is fixed now. Part of the fix also ensure to allow multiple "primary" devices on the network to be discovered. Further also added debug logging to the setup_platform. * Further improvements Some additional improvements related to handling the DATA_DIRECTV in hass.data. * Fixed flake8 issue Fixed flake8 issue * Added available property Added available property * Updated to use get_locations() Replaced doing the request for getLocations with the get_locations() API from DirectPy instead. * Fix for checking if device is available Fix for checking if device is available and small update to debug log message. * Fixed lint issue Fixed lint issue with unused variable by adding ingore for it as this is for a enumerate * Updated try/except and removed available Updated tr/except having the except by the statement we're doing except on. Removed available, will be a different PR. * Add available property Add the available property to the entiry. --- .../components/media_player/directv.py | 116 +++++++++++++----- 1 file changed, 82 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 4767428894b..009a720276e 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -48,12 +48,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DirecTV platform.""" - known_devices = hass.data.get(DATA_DIRECTV) - if not known_devices: - known_devices = [] + known_devices = hass.data.get(DATA_DIRECTV, []) hosts = [] if CONF_HOST in config: + _LOGGER.debug("Adding configured device %s with client address %s ", + config.get(CONF_NAME), config.get(CONF_DEVICE)) hosts.append([ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), config.get(CONF_DEVICE) @@ -64,29 +64,57 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = 'DirecTV_{}'.format(discovery_info.get('serial', '')) # Attempt to discover additional RVU units - try: - resp = requests.get( - 'http://%s:%d/info/getLocations' % (host, DEFAULT_PORT)).json() - if "locations" in resp: - for loc in resp["locations"]: - if("locationName" in loc and "clientAddr" in loc - and loc["clientAddr"] not in known_devices): - hosts.append([str.title(loc["locationName"]), host, - DEFAULT_PORT, loc["clientAddr"]]) + _LOGGER.debug("Doing discovery of DirecTV devices on %s", host) - except requests.exceptions.RequestException: + from DirectPy import DIRECTV + dtv = DIRECTV(host, DEFAULT_PORT) + try: + resp = dtv.get_locations() + except requests.exceptions.RequestException as ex: # Bail out and just go forward with uPnP data - if DEFAULT_DEVICE not in known_devices: - hosts.append([name, host, DEFAULT_PORT, DEFAULT_DEVICE]) + # Make sure that this device is not already configured + # Comparing based on host (IP) and clientAddr. + _LOGGER.debug("Request exception %s trying to get locations", ex) + resp = { + 'locations': [{ + 'locationName': name, + 'clientAddr': DEFAULT_DEVICE + }] + } + + _LOGGER.debug("Known devices: %s", known_devices) + for loc in resp.get("locations") or []: + if "locationName" not in loc or "clientAddr" not in loc: + continue + + # Make sure that this device is not already configured + # Comparing based on host (IP) and clientAddr. + device_unknown = True + for device in known_devices: + if host in device and loc["clientAddr"] in device: + device_unknown = False + _LOGGER.debug("Discovered device %s on host %s with " + "client address %s is already " + "configured", + str.title(loc["locationName"]), + host, loc["clientAddr"]) + break + + if device_unknown: + _LOGGER.debug("Adding discovered device %s with" + " client address %s", + str.title(loc["locationName"]), + loc["clientAddr"]) + hosts.append([str.title(loc["locationName"]), host, + DEFAULT_PORT, loc["clientAddr"]]) dtvs = [] for host in hosts: dtvs.append(DirecTvDevice(*host)) - known_devices.append(host[-1]) + hass.data.setdefault(DATA_DIRECTV, []).append(host) add_entities(dtvs) - hass.data[DATA_DIRECTV] = known_devices class DirecTvDevice(MediaPlayerDevice): @@ -104,28 +132,43 @@ class DirecTvDevice(MediaPlayerDevice): self._last_position = None self._is_recorded = None self._assumed_state = None + self._available = False _LOGGER.debug("Created DirecTV device for %s", self._name) def update(self): """Retrieve latest state.""" - _LOGGER.debug("Updating state for %s", self._name) - self._is_standby = self.dtv.get_standby() - if self._is_standby: - self._current = None - self._is_recorded = None - self._paused = None - self._assumed_state = False - self._last_position = None - self._last_update = None - else: - self._current = self.dtv.get_tuned() - self._is_recorded = self._current.get('uniqueId') is not None - self._paused = self._last_position == self._current['offset'] - self._assumed_state = self._is_recorded - self._last_position = self._current['offset'] - self._last_update = dt_util.now() if not self._paused or\ - self._last_update is None else self._last_update + _LOGGER.debug("Updating status for %s", self._name) + try: + self._available = True + self._is_standby = self.dtv.get_standby() + if self._is_standby: + self._current = None + self._is_recorded = None + self._paused = None + self._assumed_state = False + self._last_position = None + self._last_update = None + else: + self._current = self.dtv.get_tuned() + if self._current['status']['code'] == 200: + self._is_recorded = self._current.get('uniqueId')\ + is not None + self._paused = self._last_position == \ + self._current['offset'] + self._assumed_state = self._is_recorded + self._last_position = self._current['offset'] + self._last_update = dt_util.now() if not self._paused or\ + self._last_update is None else self._last_update + else: + self._available = False + except requests.RequestException as ex: + _LOGGER.error("Request error trying to update current status for" + " %s. %s", self._name, ex) + self._available = False + except Exception: + self._available = False + raise @property def device_state_attributes(self): @@ -160,6 +203,11 @@ class DirecTvDevice(MediaPlayerDevice): return STATE_PLAYING + @property + def available(self): + """Return if able to retrieve information from DVR or not.""" + return self._available + @property def assumed_state(self): """Return if we assume the state or not.""" From dcdae325eac13923a535683a56c3532dfc787300 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 5 Nov 2018 16:21:44 +0100 Subject: [PATCH 031/238] deCONZ - reflect hub status on entities (#18106) * Support for controlling entity available attribute based on gateways availability * Fix string not being in imperative mood --- .../components/binary_sensor/deconz.py | 27 +++++++---- homeassistant/components/cover/deconz.py | 29 +++++++---- homeassistant/components/deconz/const.py | 5 +- homeassistant/components/deconz/gateway.py | 19 ++++++-- homeassistant/components/light/deconz.py | 31 +++++++----- homeassistant/components/scene/deconz.py | 16 ++++--- homeassistant/components/sensor/deconz.py | 48 +++++++++++++------ homeassistant/components/switch/deconz.py | 27 +++++++---- 8 files changed, 131 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index fe00402ec95..b9fdb08e068 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -6,8 +6,8 @@ https://home-assistant.io/components/binary_sensor.deconz/ """ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.deconz.const import ( - ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, - DECONZ_DOMAIN) + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DECONZ_REACHABLE, + DOMAIN as DECONZ_DOMAIN) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE @@ -24,6 +24,8 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ binary sensor.""" + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_sensor(sensors): """Add binary sensor from deCONZ.""" @@ -33,30 +35,35 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: if sensor.type in DECONZ_BINARY_SENSOR and \ not (not allow_clip_sensor and sensor.type.startswith('CLIP')): - entities.append(DeconzBinarySensor(sensor)) + entities.append(DeconzBinarySensor(sensor, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) - async_add_sensor(hass.data[DATA_DECONZ].api.sensors.values()) + async_add_sensor(gateway.api.sensors.values()) class DeconzBinarySensor(BinarySensorDevice): """Representation of a binary sensor.""" - def __init__(self, sensor): + def __init__(self, sensor, gateway): """Set up sensor and add update callback to get data from websocket.""" self._sensor = sensor + self.gateway = gateway + self.unsub_dispatcher = None async def async_added_to_hass(self): """Subscribe sensors events.""" self._sensor.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._sensor.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect sensor object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._sensor.remove_callback(self.async_update_callback) self._sensor = None @@ -101,7 +108,7 @@ class DeconzBinarySensor(BinarySensorDevice): @property def available(self): """Return True if sensor is available.""" - return self._sensor.reachable + return self.gateway.available and self._sensor.reachable @property def should_poll(self): @@ -128,7 +135,7 @@ class DeconzBinarySensor(BinarySensorDevice): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/cover/deconz.py b/homeassistant/components/cover/deconz.py index cd5871e153a..be60997869c 100644 --- a/homeassistant/components/cover/deconz.py +++ b/homeassistant/components/cover/deconz.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.deconz/ """ from homeassistant.components.deconz.const import ( - COVER_TYPES, DAMPERS, DOMAIN as DATA_DECONZ, DECONZ_DOMAIN, WINDOW_COVERS) + COVER_TYPES, DAMPERS, DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, + WINDOW_COVERS) from homeassistant.components.cover import ( ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, SUPPORT_SET_POSITION) @@ -29,6 +30,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): Covers are based on same device class as lights in deCONZ. """ + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_cover(lights): """Add cover from deCONZ.""" @@ -36,23 +39,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if light.type in COVER_TYPES: if light.modelid in ZIGBEE_SPEC: - entities.append(DeconzCoverZigbeeSpec(light)) + entities.append(DeconzCoverZigbeeSpec(light, gateway)) else: - entities.append(DeconzCover(light)) + entities.append(DeconzCover(light, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_light', async_add_cover)) - async_add_cover(hass.data[DATA_DECONZ].api.lights.values()) + async_add_cover(gateway.api.lights.values()) class DeconzCover(CoverDevice): """Representation of a deCONZ cover.""" - def __init__(self, cover): + def __init__(self, cover, gateway): """Set up cover and add update callback to get data from websocket.""" self._cover = cover + self.gateway = gateway + self.unsub_dispatcher = None + self._features = SUPPORT_OPEN self._features |= SUPPORT_CLOSE self._features |= SUPPORT_STOP @@ -61,11 +67,14 @@ class DeconzCover(CoverDevice): async def async_added_to_hass(self): """Subscribe to covers events.""" self._cover.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._cover.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._cover.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect cover object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._cover.remove_callback(self.async_update_callback) self._cover = None @@ -112,7 +121,7 @@ class DeconzCover(CoverDevice): @property def available(self): """Return True if light is available.""" - return self._cover.reachable + return self.gateway.available and self._cover.reachable @property def should_poll(self): @@ -150,7 +159,7 @@ class DeconzCover(CoverDevice): self._cover.uniqueid.count(':') != 7): return None serial = self._cover.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index ccd1eac77ea..d856d8c1465 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -5,9 +5,6 @@ _LOGGER = logging.getLogger('homeassistant.components.deconz') DOMAIN = 'deconz' CONFIG_FILE = 'deconz.conf' -DATA_DECONZ_EVENT = 'deconz_events' -DATA_DECONZ_ID = 'deconz_entities' -DATA_DECONZ_UNSUB = 'deconz_dispatchers' DECONZ_DOMAIN = 'deconz' CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' @@ -16,6 +13,8 @@ CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' SUPPORTED_PLATFORMS = ['binary_sensor', 'cover', 'light', 'scene', 'sensor', 'switch'] +DECONZ_REACHABLE = 'deconz_reachable' + ATTR_DARK = 'dark' ATTR_ON = 'on' diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index a64f9af886b..8d33e011b94 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -8,7 +8,7 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.util import slugify from .const import ( - _LOGGER, CONF_ALLOW_CLIP_SENSOR, SUPPORTED_PLATFORMS) + _LOGGER, DECONZ_REACHABLE, CONF_ALLOW_CLIP_SENSOR, SUPPORTED_PLATFORMS) class DeconzGateway: @@ -18,6 +18,7 @@ class DeconzGateway: """Initialize the system.""" self.hass = hass self.config_entry = config_entry + self.available = True self.api = None self._cancel_retry_setup = None @@ -30,7 +31,8 @@ class DeconzGateway: hass = self.hass self.api = await get_gateway( - hass, self.config_entry.data, self.async_add_device_callback + hass, self.config_entry.data, self.async_add_device_callback, + self.async_connection_status_callback ) if self.api is False: @@ -65,6 +67,13 @@ class DeconzGateway: return True + @callback + def async_connection_status_callback(self, available): + """Handle signals of gateway connection status.""" + self.available = available + async_dispatcher_send( + self.hass, DECONZ_REACHABLE, {'state': True, 'attr': 'reachable'}) + @callback def async_add_device_callback(self, device_type, device): """Handle event of new device creation in deCONZ.""" @@ -122,13 +131,15 @@ class DeconzGateway: return True -async def get_gateway(hass, config, async_add_device_callback): +async def get_gateway(hass, config, async_add_device_callback, + async_connection_status_callback): """Create a gateway object and verify configuration.""" from pydeconz import DeconzSession session = aiohttp_client.async_get_clientsession(hass) deconz = DeconzSession(hass.loop, session, **config, - async_add_device=async_add_device_callback) + async_add_device=async_add_device_callback, + connection_status=async_connection_status_callback) result = await deconz.async_load_parameters() if result: diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 61f5ea39603..ae2d241d81f 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -5,7 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.deconz/ """ from homeassistant.components.deconz.const import ( - CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DATA_DECONZ, DECONZ_DOMAIN, + CONF_ALLOW_DECONZ_GROUPS, DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, COVER_TYPES, SWITCH_TYPES) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -28,16 +28,18 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ lights and groups from a config entry.""" + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_light(lights): """Add light from deCONZ.""" entities = [] for light in lights: if light.type not in COVER_TYPES + SWITCH_TYPES: - entities.append(DeconzLight(light)) + entities.append(DeconzLight(light, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_light', async_add_light)) @callback @@ -47,22 +49,24 @@ async def async_setup_entry(hass, config_entry, async_add_entities): allow_group = config_entry.data.get(CONF_ALLOW_DECONZ_GROUPS, True) for group in groups: if group.lights and allow_group: - entities.append(DeconzLight(group)) + entities.append(DeconzLight(group, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_group', async_add_group)) - async_add_light(hass.data[DATA_DECONZ].api.lights.values()) - async_add_group(hass.data[DATA_DECONZ].api.groups.values()) + async_add_light(gateway.api.lights.values()) + async_add_group(gateway.api.groups.values()) class DeconzLight(Light): """Representation of a deCONZ light.""" - def __init__(self, light): + def __init__(self, light, gateway): """Set up light and add update callback to get data from websocket.""" self._light = light + self.gateway = gateway + self.unsub_dispatcher = None self._features = SUPPORT_BRIGHTNESS self._features |= SUPPORT_FLASH @@ -80,11 +84,14 @@ class DeconzLight(Light): async def async_added_to_hass(self): """Subscribe to lights events.""" self._light.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._light.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._light.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect light object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._light.remove_callback(self.async_update_callback) self._light = None @@ -141,7 +148,7 @@ class DeconzLight(Light): @property def available(self): """Return True if light is available.""" - return self._light.reachable + return self.gateway.available and self._light.reachable @property def should_poll(self): @@ -214,7 +221,7 @@ class DeconzLight(Light): self._light.uniqueid.count(':') != 7): return None serial = self._light.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/scene/deconz.py b/homeassistant/components/scene/deconz.py index 6319e52f6ef..05845a02288 100644 --- a/homeassistant/components/scene/deconz.py +++ b/homeassistant/components/scene/deconz.py @@ -4,7 +4,7 @@ Support for deCONZ scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.deconz/ """ -from homeassistant.components.deconz import DOMAIN as DATA_DECONZ +from homeassistant.components.deconz import DOMAIN as DECONZ_DOMAIN from homeassistant.components.scene import Scene from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,30 +20,32 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities): """Set up scenes for deCONZ component.""" + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_scene(scenes): """Add scene from deCONZ.""" entities = [] for scene in scenes: - entities.append(DeconzScene(scene)) + entities.append(DeconzScene(scene, gateway)) async_add_entities(entities) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_scene', async_add_scene)) - async_add_scene(hass.data[DATA_DECONZ].api.scenes.values()) + async_add_scene(gateway.api.scenes.values()) class DeconzScene(Scene): """Representation of a deCONZ scene.""" - def __init__(self, scene): + def __init__(self, scene, gateway): """Set up a scene.""" self._scene = scene + self.gateway = gateway async def async_added_to_hass(self): """Subscribe to sensors events.""" - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._scene.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id async def async_will_remove_from_hass(self) -> None: """Disconnect scene object when removed.""" diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 99f450d018e..e2c9b59c59c 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -5,8 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/sensor.deconz/ """ from homeassistant.components.deconz.const import ( - ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, - DECONZ_DOMAIN) + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DECONZ_REACHABLE, + DOMAIN as DECONZ_DOMAIN) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) from homeassistant.core import callback @@ -30,6 +30,8 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ sensors.""" + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_sensor(sensors): """Add sensors from deCONZ.""" @@ -41,32 +43,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities): not (not allow_clip_sensor and sensor.type.startswith('CLIP')): if sensor.type in DECONZ_REMOTE: if sensor.battery: - entities.append(DeconzBattery(sensor)) + entities.append(DeconzBattery(sensor, gateway)) else: - entities.append(DeconzSensor(sensor)) + entities.append(DeconzSensor(sensor, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) - async_add_sensor(hass.data[DATA_DECONZ].api.sensors.values()) + async_add_sensor(gateway.api.sensors.values()) class DeconzSensor(Entity): """Representation of a sensor.""" - def __init__(self, sensor): + def __init__(self, sensor, gateway): """Set up sensor and add update callback to get data from websocket.""" self._sensor = sensor + self.gateway = gateway + self.unsub_dispatcher = None async def async_added_to_hass(self): """Subscribe to sensors events.""" self._sensor.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._sensor.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect sensor object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._sensor.remove_callback(self.async_update_callback) self._sensor = None @@ -116,7 +123,7 @@ class DeconzSensor(Entity): @property def available(self): """Return true if sensor is available.""" - return self._sensor.reachable + return self.gateway.available and self._sensor.reachable @property def should_poll(self): @@ -148,7 +155,7 @@ class DeconzSensor(Entity): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, @@ -163,20 +170,26 @@ class DeconzSensor(Entity): class DeconzBattery(Entity): """Battery class for when a device is only represented as an event.""" - def __init__(self, sensor): + def __init__(self, sensor, gateway): """Register dispatcher callback for update of battery state.""" self._sensor = sensor + self.gateway = gateway + self.unsub_dispatcher = None + self._name = '{} {}'.format(self._sensor.name, 'Battery Level') self._unit_of_measurement = "%" async def async_added_to_hass(self): """Subscribe to sensors events.""" self._sensor.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._sensor.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect sensor object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._sensor.remove_callback(self.async_update_callback) self._sensor = None @@ -211,6 +224,11 @@ class DeconzBattery(Entity): """Return the unit of measurement of this entity.""" return self._unit_of_measurement + @property + def available(self): + """Return true if sensor is available.""" + return self.gateway.available and self._sensor.reachable + @property def should_poll(self): """No polling needed.""" @@ -231,7 +249,7 @@ class DeconzBattery(Entity): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/switch/deconz.py b/homeassistant/components/switch/deconz.py index 4c2fcca052c..b491bc4b567 100644 --- a/homeassistant/components/switch/deconz.py +++ b/homeassistant/components/switch/deconz.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.deconz/ """ from homeassistant.components.deconz.const import ( - DOMAIN as DATA_DECONZ, DECONZ_DOMAIN, POWER_PLUGS, SIRENS) + DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, POWER_PLUGS, SIRENS) from homeassistant.components.switch import SwitchDevice from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE @@ -25,38 +25,45 @@ async def async_setup_entry(hass, config_entry, async_add_entities): Switches are based same device class as lights in deCONZ. """ + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_switch(lights): """Add switch from deCONZ.""" entities = [] for light in lights: if light.type in POWER_PLUGS: - entities.append(DeconzPowerPlug(light)) + entities.append(DeconzPowerPlug(light, gateway)) elif light.type in SIRENS: - entities.append(DeconzSiren(light)) + entities.append(DeconzSiren(light, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_light', async_add_switch)) - async_add_switch(hass.data[DATA_DECONZ].api.lights.values()) + async_add_switch(gateway.api.lights.values()) class DeconzSwitch(SwitchDevice): """Representation of a deCONZ switch.""" - def __init__(self, switch): + def __init__(self, switch, gateway): """Set up switch and add update callback to get data from websocket.""" self._switch = switch + self.gateway = gateway + self.unsub_dispatcher = None async def async_added_to_hass(self): """Subscribe to switches events.""" self._switch.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._switch.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._switch.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect switch object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._switch.remove_callback(self.async_update_callback) self._switch = None @@ -78,7 +85,7 @@ class DeconzSwitch(SwitchDevice): @property def available(self): """Return True if light is available.""" - return self._switch.reachable + return self.gateway.available and self._switch.reachable @property def should_poll(self): @@ -92,7 +99,7 @@ class DeconzSwitch(SwitchDevice): self._switch.uniqueid.count(':') != 7): return None serial = self._switch.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, From 26ba4a56e8e457a48dc8cbeda2783455b2a66211 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Mon, 5 Nov 2018 16:42:19 +0100 Subject: [PATCH 032/238] Ignore duplicate state changes GarageDoor HomeKit (#18149) * Ignore duplicate state changes GarageDoor HomeKit * Don't ignore service_call --- .../components/homekit/type_covers.py | 6 ++++-- tests/components/homekit/test_type_covers.py | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 840800f730b..b3beb11c8b6 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -46,10 +46,12 @@ class GarageDoorOpener(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if value == 0: - self.char_current_state.set_value(3) + if self.char_current_state.value != value: + self.char_current_state.set_value(3) self.call_service(DOMAIN, SERVICE_OPEN_COVER, params) elif value == 1: - self.char_current_state.set_value(2) + if self.char_current_state.value != value: + self.char_current_state.set_value(2) self.call_service(DOMAIN, SERVICE_CLOSE_COVER, params) def update_state(self, new_state): diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index c32abaef0dd..a39af399dce 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -80,13 +80,30 @@ async def test_garage_door_open_close(hass, hk_driver, cls, events): hass.states.async_set(entity_id, STATE_CLOSED) await hass.async_block_till_done() + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert acc.char_current_state.value == 1 + assert acc.char_target_state.value == 1 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None + await hass.async_add_job(acc.char_target_state.client_update_value, 0) await hass.async_block_till_done() assert call_open_cover assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 - assert len(events) == 2 + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] is None + + hass.states.async_set(entity_id, STATE_OPEN) + await hass.async_block_till_done() + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert len(events) == 4 assert events[-1].data[ATTR_VALUE] is None From b261c4b7f8fe683220652807a1c18103d1795643 Mon Sep 17 00:00:00 2001 From: vetegrodd Date: Mon, 5 Nov 2018 18:39:37 +0100 Subject: [PATCH 033/238] Activate kodi media player progress bar (#17626) * Added code for progress bar * Added doc string * Using in * More cleaning * Only update position if needed. --- homeassistant/components/media_player/kodi.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 01d8069dc3b..a83287eb617 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -33,6 +33,7 @@ from homeassistant.helpers import script from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.template import Template from homeassistant.util.yaml import dump +import homeassistant.util.dt as dt_util REQUIREMENTS = ['jsonrpc-async==0.6', 'jsonrpc-websocket==0.6'] @@ -281,6 +282,8 @@ class KodiDevice(MediaPlayerDevice): self.hass = hass self._name = name self._unique_id = unique_id + self._media_position_updated_at = None + self._media_position = None kwargs = { 'timeout': timeout, @@ -313,6 +316,7 @@ class KodiDevice(MediaPlayerDevice): self._ws_server.Player.OnAVChange = self.async_on_speed_event self._ws_server.Player.OnResume = self.async_on_speed_event self._ws_server.Player.OnSpeedChanged = self.async_on_speed_event + self._ws_server.Player.OnSeek = self.async_on_speed_event self._ws_server.Player.OnStop = self.async_on_stop self._ws_server.Application.OnVolumeChanged = \ self.async_on_volume_changed @@ -371,6 +375,8 @@ class KodiDevice(MediaPlayerDevice): self._players = [] self._properties = {} self._item = {} + self._media_position_updated_at = None + self._media_position = None self.async_schedule_update_ha_state() @callback @@ -473,6 +479,11 @@ class KodiDevice(MediaPlayerDevice): ['time', 'totaltime', 'speed', 'live'] ) + position = self._properties['time'] + if self._media_position != position: + self._media_position_updated_at = dt_util.utcnow() + self._media_position = position + self._item = (await self.server.Player.GetItem( player_id, ['title', 'file', 'uniqueid', 'thumbnail', 'artist', @@ -482,6 +493,8 @@ class KodiDevice(MediaPlayerDevice): self._properties = {} self._item = {} self._app_properties = {} + self._media_position = None + self._media_position_updated_at = None @property def server(self): @@ -543,6 +556,24 @@ class KodiDevice(MediaPlayerDevice): total_time['minutes'] * 60 + total_time['seconds']) + @property + def media_position(self): + """Position of current playing media in seconds.""" + time = self._properties.get('time') + + if time is None: + return None + + return ( + time['hours'] * 3600 + + time['minutes'] * 60 + + time['seconds']) + + @property + def media_position_updated_at(self): + """Last valid time of media position.""" + return self._media_position_updated_at + @property def media_image_url(self): """Image url of current playing media.""" From 561f6996c680005a92643132f84d2349382e20c0 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Mon, 5 Nov 2018 11:33:59 -0700 Subject: [PATCH 034/238] Duplicate entities on discovery (#18074) * Enhancements for DirecTV media player Following enhancements have been made: 1. Added debug logging 2. Added ability to change channel using select_source service of the remote platform. 3. State will now show paused if a recorded program is paused, for live TV playing will always be returned. 4. Added the following attributes: a. media_position: current position of the media (in seconds) b. media_position_updated_at: timestamp when media_position was updated. c. source: current source (channel). d. media_isbeingrecorded: if current media is being recorded or not. e. media_rating: TV/Movie rating of the media f. media_recorded: if current media is recorded or live TV g. media_starttime: Timestamp media was aired Reordered properties to follow same order as how they are in __init__.py of remote platform. * Fixed error and cleaned up few items Fixed an issue when determining if a program is recorded or not. Cleaned up some coding. * Fix issue in checking if DTV device is already configured If a DTV device was configured before, then discovery would add this device again seperately if the name specified in the configuration is different from the name on the DTV. This issue is fixed now. Part of the fix also ensure to allow multiple "primary" devices on the network to be discovered. Further also added debug logging to the setup_platform. * Further improvements Some additional improvements related to handling the DATA_DIRECTV in hass.data. * Fixed flake8 issue Fixed flake8 issue * Added available property Added available property * Updated to use get_locations() Replaced doing the request for getLocations with the get_locations() API from DirectPy instead. * Fix for checking if device is available Fix for checking if device is available and small update to debug log message. * Fixed lint issue Fixed lint issue with unused variable by adding ingore for it as this is for a enumerate * Updated try/except and removed available Updated tr/except having the except by the statement we're doing except on. Removed available, will be a different PR. * Updated known_devices to be tupples in a set Updated known_devices to be a tupple in a set, removing loop to determine if client was already added. --- .../components/media_player/directv.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 009a720276e..7ae80172fac 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -48,7 +48,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DirecTV platform.""" - known_devices = hass.data.get(DATA_DIRECTV, []) + known_devices = hass.data.get(DATA_DIRECTV, set()) hosts = [] if CONF_HOST in config: @@ -89,18 +89,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Make sure that this device is not already configured # Comparing based on host (IP) and clientAddr. - device_unknown = True - for device in known_devices: - if host in device and loc["clientAddr"] in device: - device_unknown = False - _LOGGER.debug("Discovered device %s on host %s with " - "client address %s is already " - "configured", - str.title(loc["locationName"]), - host, loc["clientAddr"]) - break - - if device_unknown: + if (host, loc["clientAddr"]) in known_devices: + _LOGGER.debug("Discovered device %s on host %s with " + "client address %s is already " + "configured", + str.title(loc["locationName"]), + host, loc["clientAddr"]) + else: _LOGGER.debug("Adding discovered device %s with" " client address %s", str.title(loc["locationName"]), @@ -112,7 +107,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for host in hosts: dtvs.append(DirecTvDevice(*host)) - hass.data.setdefault(DATA_DIRECTV, []).append(host) + hass.data.setdefault(DATA_DIRECTV, set()).add((host[1], host[3])) add_entities(dtvs) From 93b16e7efbc8ef1eaf0f7559161cbd674f65775e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 5 Nov 2018 20:52:34 +0100 Subject: [PATCH 035/238] Mill room temp (#18203) * mill, avg room temp * typo * Mill device_state_attributes --- homeassistant/components/climate/mill.py | 15 ++++++++------- requirements_all.txt | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index a533cc37fd3..f4615ee6517 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.2'] +REQUIREMENTS = ['millheater==0.2.3'] _LOGGER = logging.getLogger(__name__) @@ -112,16 +112,17 @@ class MillHeater(ClimateDevice): @property def device_state_attributes(self): """Return the state attributes.""" - if self._heater.room: - room = self._heater.room.name - else: - room = "Independent device" - return { - "room": room, + res = { "open_window": self._heater.open_window, "heating": self._heater.is_heating, "controlled_by_tibber": self._heater.tibber_control, } + if self._heater.room: + res['room'] = self._heater.room.name + res['avg_room_temp'] = self._heater.room.avg_temp + else: + res['room'] = "Independent device" + return res @property def temperature_unit(self): diff --git a/requirements_all.txt b/requirements_all.txt index 1edd30b6735..91c6e345e2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -619,7 +619,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.2 +millheater==0.2.3 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 From c59b038512d913375683bf53afea6a8d7104796a Mon Sep 17 00:00:00 2001 From: quthla Date: Mon, 5 Nov 2018 21:36:30 +0100 Subject: [PATCH 036/238] Add scenes as switches HomeKit (#17799) --- homeassistant/components/homekit/__init__.py | 3 ++- homeassistant/components/homekit/type_switches.py | 2 ++ tests/components/homekit/test_get_accessories.py | 1 + tests/components/homekit/test_type_switches.py | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 1c30de918e3..f8514a5d030 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -170,7 +170,8 @@ def get_accessory(hass, driver, state, aid, config): switch_type = config.get(CONF_TYPE, TYPE_SWITCH) a_type = SWITCH_TYPES[switch_type] - elif state.domain in ('automation', 'input_boolean', 'remote', 'script'): + elif state.domain in ('automation', 'input_boolean', 'remote', 'scene', + 'script'): a_type = 'Switch' elif state.domain == 'water_heater': diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 75a9a31bee1..553d74f5a52 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -86,6 +86,8 @@ class Switch(HomeAccessory): def is_activate(self, state): """Check if entity is activate only.""" can_cancel = state.attributes.get(ATTR_CAN_CANCEL) + if self._domain == 'scene': + return True if self._domain == 'script' and not can_cancel: return True return False diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 7d303c38e93..d39609b079a 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -139,6 +139,7 @@ def test_type_sensors(type_name, entity_id, state, attrs): ('Switch', 'automation.test', 'on', {}, {}), ('Switch', 'input_boolean.test', 'on', {}, {}), ('Switch', 'remote.test', 'on', {}, {}), + ('Switch', 'scene.test', 'on', {}, {}), ('Switch', 'script.test', 'on', {}, {}), ('Switch', 'switch.test', 'on', {}, {}), ('Switch', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SWITCH}), diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index c94929e560c..204cc90207c 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -180,6 +180,7 @@ async def test_valve_set_state(hass, hk_driver, events): @pytest.mark.parametrize('entity_id, attrs', [ + ('scene.test', {}), ('script.test', {}), ('script.test', {ATTR_CAN_CANCEL: False}), ]) From abf147ed57fa2cad58ef90e46bf1e4be11d6c7dc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 5 Nov 2018 21:41:19 +0100 Subject: [PATCH 037/238] Check if os has chown (#18229) --- homeassistant/util/ruamel_yaml.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py index c3035811344..eb3e935c6ce 100644 --- a/homeassistant/util/ruamel_yaml.py +++ b/homeassistant/util/ruamel_yaml.py @@ -109,10 +109,11 @@ def save_yaml(fname: str, data: JSON_TYPE) -> None: as temp_file: yaml.dump(data, temp_file) os.replace(tmp_fname, fname) - try: - os.chown(fname, file_stat.st_uid, file_stat.st_gid) - except OSError: - pass + if hasattr(os, 'chown'): + try: + os.chown(fname, file_stat.st_uid, file_stat.st_gid) + except OSError: + pass except YAMLError as exc: _LOGGER.error(str(exc)) raise HomeAssistantError(exc) From 46b5b6240f910ca4f21f73ede9ee3544576db8fb Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Mon, 5 Nov 2018 15:12:46 -0700 Subject: [PATCH 038/238] Improve debug log information (#18230) Added debug log information for when records are purged and added entity_id to existing debug information to identify the entity the debug information is for. --- homeassistant/components/sensor/statistics.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 453acb94b11..6181a4ae094 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -173,12 +173,20 @@ class StatisticsSensor(Entity): """Remove states which are older than self._max_age.""" now = dt_util.utcnow() + _LOGGER.debug("%s: purging records older then %s(%s)", + self.entity_id, dt_util.as_local(now - self._max_age), + self._max_age) + while self.ages and (now - self.ages[0]) > self._max_age: + _LOGGER.debug("%s: purging record with datetime %s(%s)", + self.entity_id, dt_util.as_local(self.ages[0]), + (now - self.ages[0])) self.ages.popleft() self.states.popleft() async def async_update(self): """Get the latest data and updates the states.""" + _LOGGER.debug("%s: updating statistics.", self.entity_id) if self._max_age is not None: self._purge_old() @@ -191,7 +199,7 @@ class StatisticsSensor(Entity): self.median = round(statistics.median(self.states), self._precision) except statistics.StatisticsError as err: - _LOGGER.debug(err) + _LOGGER.debug("%s: %s", self.entity_id, err) self.mean = self.median = STATE_UNKNOWN try: # require at least two data points @@ -200,7 +208,7 @@ class StatisticsSensor(Entity): self.variance = round(statistics.variance(self.states), self._precision) except statistics.StatisticsError as err: - _LOGGER.debug(err) + _LOGGER.debug("%s: %s", self.entity_id, err) self.stdev = self.variance = STATE_UNKNOWN if self.states: @@ -241,7 +249,7 @@ class StatisticsSensor(Entity): list so that we get it in the right order again. """ from homeassistant.components.recorder.models import States - _LOGGER.debug("initializing values for %s from the database", + _LOGGER.debug("%s: initializing values from the database", self.entity_id) with session_scope(hass=self._hass) as session: @@ -254,4 +262,5 @@ class StatisticsSensor(Entity): for state in reversed(states): self._add_state_to_queue(state) - _LOGGER.debug("initializing from database completed") + _LOGGER.debug("%s: initializing from database completed", + self.entity_id) From 3366d2c1ad0e71bde1cd35c56f3281ff0a510dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 5 Nov 2018 23:29:42 +0100 Subject: [PATCH 039/238] Tibber login validate (#18235) * tibber login validate * requirements --- homeassistant/components/tibber/__init__.py | 5 ++++- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 8022902c580..38605e949bb 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.7.2'] +REQUIREMENTS = ['pyTibber==0.7.4'] DOMAIN = 'tibber' @@ -47,6 +47,9 @@ async def async_setup(hass, config): await tibber_connection.update_info() except (asyncio.TimeoutError, aiohttp.ClientError): return False + except tibber.InvalidLogin as exp: + _LOGGER.error("Failed to login. %s", exp) + return False for component in ['sensor', 'notify']: discovery.load_platform(hass, component, DOMAIN, diff --git a/requirements_all.txt b/requirements_all.txt index 91c6e345e2c..7eaa8bf696e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -800,7 +800,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.2 # homeassistant.components.tibber -pyTibber==0.7.2 +pyTibber==0.7.4 # homeassistant.components.switch.dlink pyW215==0.6.0 From d951ed4d68ac3030245e842ad18517dca40c1e66 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Tue, 6 Nov 2018 00:09:15 +0100 Subject: [PATCH 040/238] Add Xiaomi Smartmi Fresh Air System support (#18097) * Add Xiaomi Air Fresh VA2 support * Add LED property again (available now) --- homeassistant/components/fan/xiaomi_miio.py | 170 ++++++++++++++++++-- 1 file changed, 161 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 35bb92fa610..a1917495732 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -11,10 +11,10 @@ import logging import voluptuous as vol -from homeassistant.components.fan import ( - DOMAIN, PLATFORM_SCHEMA, SUPPORT_SET_SPEED, FanEntity) -from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN) +from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, + SUPPORT_SET_SPEED, DOMAIN, ) +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, + ATTR_ENTITY_ID, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -30,6 +30,7 @@ MODEL_AIRPURIFIER_PRO = 'zhimi.airpurifier.v6' MODEL_AIRPURIFIER_V3 = 'zhimi.airpurifier.v3' MODEL_AIRHUMIDIFIER_V1 = 'zhimi.humidifier.v1' MODEL_AIRHUMIDIFIER_CA = 'zhimi.humidifier.ca1' +MODEL_AIRFRESH_VA2 = 'zhimi.airfresh.va2' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -48,7 +49,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'zhimi.airpurifier.v5', 'zhimi.airpurifier.v6', 'zhimi.humidifier.v1', - 'zhimi.humidifier.ca1']), + 'zhimi.humidifier.ca1', + 'zhimi.airfresh.va2']), }) ATTR_MODEL = 'model' @@ -97,6 +99,9 @@ ATTR_SPEED = 'speed' ATTR_DEPTH = 'depth' ATTR_DRY = 'dry' +# Air Fresh +ATTR_CO2 = 'co2' + # Map attributes to properties of the state object AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { ATTR_TEMPERATURE: 'temperature', @@ -166,31 +171,55 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { ATTR_BUTTON_PRESSED: 'button_pressed', } -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = { +AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON = { ATTR_TEMPERATURE: 'temperature', ATTR_HUMIDITY: 'humidity', ATTR_MODE: 'mode', ATTR_BUZZER: 'buzzer', ATTR_CHILD_LOCK: 'child_lock', - ATTR_TRANS_LEVEL: 'trans_level', ATTR_TARGET_HUMIDITY: 'target_humidity', ATTR_LED_BRIGHTNESS: 'led_brightness', - ATTR_BUTTON_PRESSED: 'button_pressed', ATTR_USE_TIME: 'use_time', ATTR_HARDWARE_VERSION: 'hardware_version', } +AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = { + **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, + ATTR_TRANS_LEVEL: 'trans_level', + ATTR_BUTTON_PRESSED: 'button_pressed', +} + AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA = { - **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER, + **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, ATTR_SPEED: 'speed', ATTR_DEPTH: 'depth', ATTR_DRY: 'dry', } +AVAILABLE_ATTRIBUTES_AIRFRESH = { + ATTR_TEMPERATURE: 'temperature', + ATTR_AIR_QUALITY_INDEX: 'aqi', + ATTR_AVERAGE_AIR_QUALITY_INDEX: 'average_aqi', + ATTR_CO2: 'co2', + ATTR_HUMIDITY: 'humidity', + ATTR_MODE: 'mode', + ATTR_LED: 'led', + ATTR_LED_BRIGHTNESS: 'led_brightness', + ATTR_BUZZER: 'buzzer', + ATTR_CHILD_LOCK: 'child_lock', + ATTR_FILTER_LIFE: 'filter_life_remaining', + ATTR_FILTER_HOURS_USED: 'filter_hours_used', + ATTR_USE_TIME: 'use_time', + ATTR_MOTOR_SPEED: 'motor_speed', + ATTR_EXTRA_FEATURES: 'extra_features', +} + OPERATION_MODES_AIRPURIFIER = ['Auto', 'Silent', 'Favorite', 'Idle'] OPERATION_MODES_AIRPURIFIER_PRO = ['Auto', 'Silent', 'Favorite'] OPERATION_MODES_AIRPURIFIER_V3 = ['Auto', 'Silent', 'Favorite', 'Idle', 'Medium', 'High', 'Strong'] +OPERATION_MODES_AIRFRESH = ['Auto', 'Silent', 'Interval', 'Low', + 'Middle', 'Strong'] SUCCESS = ['ok'] @@ -234,6 +263,12 @@ FEATURE_FLAGS_AIRHUMIDIFIER = (FEATURE_FLAGS_GENERIC | FEATURE_FLAGS_AIRHUMIDIFIER_CA = (FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY) +FEATURE_FLAGS_AIRFRESH = (FEATURE_FLAGS_GENERIC | + FEATURE_SET_LED | + FEATURE_SET_LED_BRIGHTNESS | + FEATURE_RESET_FILTER | + FEATURE_SET_EXTRA_FEATURES) + SERVICE_SET_BUZZER_ON = 'xiaomi_miio_set_buzzer_on' SERVICE_SET_BUZZER_OFF = 'xiaomi_miio_set_buzzer_off' SERVICE_SET_LED_ON = 'xiaomi_miio_set_led_on' @@ -350,6 +385,10 @@ async def async_setup_platform(hass, config, async_add_entities, from miio import AirHumidifier air_humidifier = AirHumidifier(host, token, model=model) device = XiaomiAirHumidifier(name, air_humidifier, model, unique_id) + elif model.startswith('zhimi.airfresh.'): + from miio import AirFresh + air_fresh = AirFresh(host, token) + device = XiaomiAirFresh(name, air_fresh, model, unique_id) else: _LOGGER.error( 'Unsupported device found! Please create an issue at ' @@ -812,3 +851,116 @@ class XiaomiAirHumidifier(XiaomiGenericDevice): await self._try_command( "Turning the dry mode of the miio device off failed.", self._device.set_dry, False) + + +class XiaomiAirFresh(XiaomiGenericDevice): + """Representation of a Xiaomi Air Fresh.""" + + def __init__(self, name, device, model, unique_id): + """Initialize the miio device.""" + super().__init__(name, device, model, unique_id) + + self._device_features = FEATURE_FLAGS_AIRFRESH + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRFRESH + self._speed_list = OPERATION_MODES_AIRFRESH + self._state_attrs.update( + {attribute: None for attribute in self._available_attributes}) + + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = await self.hass.async_add_job( + self._device.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.is_on + self._state_attrs.update( + {key: self._extract_value_from_attribute(state, value) for + key, value in self._available_attributes.items()}) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return self._speed_list + + @property + def speed(self): + """Return the current speed.""" + if self._state: + from miio.airfresh import OperationMode + + return OperationMode(self._state_attrs[ATTR_MODE]).name + + return None + + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if self.supported_features & SUPPORT_SET_SPEED == 0: + return + + from miio.airfresh import OperationMode + + _LOGGER.debug("Setting the operation mode to: %s", speed) + + await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, OperationMode[speed.title()]) + + async def async_set_led_on(self): + """Turn the led on.""" + if self._device_features & FEATURE_SET_LED == 0: + return + + await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, True) + + async def async_set_led_off(self): + """Turn the led off.""" + if self._device_features & FEATURE_SET_LED == 0: + return + + await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, False) + + async def async_set_led_brightness(self, brightness: int = 2): + """Set the led brightness.""" + if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: + return + + from miio.airfresh import LedBrightness + + await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, LedBrightness(brightness)) + + async def async_set_extra_features(self, features: int = 1): + """Set the extra features.""" + if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0: + return + + await self._try_command( + "Setting the extra features of the miio device failed.", + self._device.set_extra_features, features) + + async def async_reset_filter(self): + """Reset the filter lifetime and usage.""" + if self._device_features & FEATURE_RESET_FILTER == 0: + return + + await self._try_command( + "Resetting the filter lifetime of the miio device failed.", + self._device.reset_filter) From 6f568d1cf6e8fe97b4c2c9228ffe77151286782d Mon Sep 17 00:00:00 2001 From: Mikko Tapionlinna Date: Tue, 6 Nov 2018 02:00:46 +0200 Subject: [PATCH 041/238] Update pynetgear to 0.5.1 (#18238) --- homeassistant/components/device_tracker/netgear.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 12d026a35cd..99d379fb4d3 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_DEVICES, CONF_EXCLUDE) -REQUIREMENTS = ['pynetgear==0.5.0'] +REQUIREMENTS = ['pynetgear==0.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7eaa8bf696e..7157f02875d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1026,7 +1026,7 @@ pymysensors==0.18.0 pynello==1.5.1 # homeassistant.components.device_tracker.netgear -pynetgear==0.5.0 +pynetgear==0.5.1 # homeassistant.components.switch.netio pynetio==0.1.9.1 From 7077e19cf8cc79dfa0ca1c4036152ad969f78e17 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Mon, 5 Nov 2018 20:09:07 -0500 Subject: [PATCH 042/238] Elk-M1 fixes (#18154) * Fix default value for temperature unit * Add defaults for subdomains * Remove unused import * Fix PR comment --- homeassistant/components/elkm1/__init__.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index aa7b9973c8e..755696b5002 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -11,7 +11,7 @@ import re import voluptuous as vol from homeassistant.const import ( CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, CONF_PASSWORD, - CONF_TEMPERATURE_UNIT, CONF_USERNAME, TEMP_FAHRENHEIT) + CONF_TEMPERATURE_UNIT, CONF_USERNAME) from homeassistant.core import HomeAssistant, callback # noqa from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery @@ -83,17 +83,17 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_USERNAME, default=''): cv.string, vol.Optional(CONF_PASSWORD, default=''): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT, default=TEMP_FAHRENHEIT): + vol.Optional(CONF_TEMPERATURE_UNIT, default='F'): cv.temperature_unit, - vol.Optional(CONF_AREA): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_COUNTER): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_KEYPAD): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_OUTPUT): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_PLC): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_SETTING): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_TASK): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_THERMOSTAT): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_ZONE): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_AREA, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_COUNTER, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_KEYPAD, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_OUTPUT, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_PLC, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_SETTING, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_TASK, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_THERMOSTAT, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_ZONE, default={}): CONFIG_SCHEMA_SUBDOMAIN, }, _host_validator, ) From 24c110ad3cac25d73c34e61ccf40148f171065ed Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 6 Nov 2018 02:12:31 +0100 Subject: [PATCH 043/238] Lovelace: Duplicate ID check on load config + caching (#18152) * Add caching + dupl. ID check * duplicate imports... * lint * remove for/else * found * Missed one... --- homeassistant/components/lovelace/__init__.py | 116 ++++++++++++------ 1 file changed, 78 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index a8cde6a2b93..39644bd047b 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -6,7 +6,9 @@ at https://www.home-assistant.io/lovelace/ """ from functools import wraps import logging +import os from typing import Dict, List, Union +import time import uuid import voluptuous as vol @@ -18,6 +20,7 @@ import homeassistant.util.ruamel_yaml as yaml _LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' +LOVELACE_DATA = 'lovelace' LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name @@ -133,9 +136,37 @@ class DuplicateIdError(HomeAssistantError): """Duplicate ID's.""" -def load_config(fname: str) -> JSON_TYPE: +def load_config(hass) -> JSON_TYPE: """Load a YAML file.""" - return yaml.load_yaml(fname, False) + fname = hass.config.path(LOVELACE_CONFIG_FILE) + + # Check for a cached version of the config + if LOVELACE_DATA in hass.data: + config, last_update = hass.data[LOVELACE_DATA] + modtime = os.path.getmtime(fname) + if config and last_update > modtime: + return config + + config = yaml.load_yaml(fname, False) + seen_card_ids = set() + seen_view_ids = set() + for view in config.get('views', []): + view_id = str(view.get('id', '')) + if view_id: + if view_id in seen_view_ids: + raise DuplicateIdError( + 'ID `{}` has multiple occurances in views'.format(view_id)) + seen_view_ids.add(view_id) + for card in view.get('cards', []): + card_id = str(card.get('id', '')) + if card_id: + if card_id in seen_card_ids: + raise DuplicateIdError( + 'ID `{}` has multiple occurances in cards' + .format(card_id)) + seen_card_ids.add(card_id) + hass.data[LOVELACE_DATA] = (config, time.time()) + return config def migrate_config(fname: str) -> None: @@ -301,35 +332,39 @@ def get_view(fname: str, view_id: str, data_format: str = FORMAT_YAML) -> None: """Get view without it's cards.""" round_trip = data_format == FORMAT_YAML config = yaml.load_yaml(fname, round_trip) + found = None for view in config.get('views', []): - if str(view.get('id', '')) != view_id: - continue - del view['cards'] - if data_format == FORMAT_YAML: - return yaml.object_to_yaml(view) - return view + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + del found['cards'] + if data_format == FORMAT_YAML: + return yaml.object_to_yaml(found) + return found def update_view(fname: str, view_id: str, view_config, data_format: str = FORMAT_YAML) -> None: """Update view.""" config = yaml.load_yaml(fname, True) + found = None for view in config.get('views', []): - if str(view.get('id', '')) != view_id: - continue - if data_format == FORMAT_YAML: - view_config = yaml.yaml_to_object(view_config) - view_config['cards'] = view.get('cards', []) - view.clear() - view.update(view_config) - yaml.save_yaml(fname, config) - return - - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) + if data_format == FORMAT_YAML: + view_config = yaml.yaml_to_object(view_config) + view_config['cards'] = found.get('cards', []) + found.clear() + found.update(view_config) + yaml.save_yaml(fname, config) def add_view(fname: str, view_config: str, @@ -350,30 +385,34 @@ def move_view(fname: str, view_id: str, position: int) -> None: """Move a view to a different position.""" config = yaml.load_yaml(fname, True) views = config.get('views', []) + found = None for view in views: - if str(view.get('id', '')) != view_id: - continue - views.insert(position, views.pop(views.index(view))) - yaml.save_yaml(fname, config) - return + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + views.insert(position, views.pop(views.index(found))) + yaml.save_yaml(fname, config) def delete_view(fname: str, view_id: str) -> None: """Delete a view.""" config = yaml.load_yaml(fname, True) views = config.get('views', []) + found = None for view in views: - if str(view.get('id', '')) != view_id: - continue - views.pop(views.index(view)) - yaml.save_yaml(fname, config) - return + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + views.pop(views.index(found)) + yaml.save_yaml(fname, config) async def async_setup(hass, config): @@ -445,6 +484,8 @@ def handle_yaml_errors(func): error = 'unsupported_error', str(err) except yaml.WriteError as err: error = 'write_error', str(err) + except DuplicateIdError as err: + error = 'duplicate_id', str(err) except CardNotFoundError as err: error = 'card_not_found', str(err) except ViewNotFoundError as err: @@ -464,8 +505,7 @@ def handle_yaml_errors(func): @handle_yaml_errors async def websocket_lovelace_config(hass, connection, msg): """Send Lovelace UI config over WebSocket configuration.""" - return await hass.async_add_executor_job( - load_config, hass.config.path(LOVELACE_CONFIG_FILE)) + return await hass.async_add_executor_job(load_config, hass) @websocket_api.async_response From c5d0440041618717147fafb7e4e7af35704bf76f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 6 Nov 2018 10:34:24 +0100 Subject: [PATCH 044/238] deCONZ - manual input fallback in config flow (#18116) * Add config flow step for manual input Remove support for loading discovery config from json file * Small cleanup Fix all translations to step user instead of step init * Revert to using step_init * Small cleanup Add test_gateway that was forgotten in a previous PR * Fix hound comment * Fix empty pydocstring --- .../components/deconz/.translations/en.json | 2 +- .../components/deconz/.translations/sv.json | 2 +- homeassistant/components/deconz/__init__.py | 11 +- .../components/deconz/config_flow.py | 28 ++- homeassistant/components/deconz/const.py | 4 +- homeassistant/components/deconz/strings.json | 2 +- tests/components/deconz/test_config_flow.py | 81 ++----- tests/components/deconz/test_gateway.py | 222 ++++++++++++++++++ tests/components/deconz/test_init.py | 22 +- 9 files changed, 274 insertions(+), 100 deletions(-) create mode 100644 tests/components/deconz/test_gateway.py diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index f55f64ca430..0c60953db56 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Host", - "port": "Port (default value: '80')" + "port": "Port" }, "title": "Define deCONZ gateway" }, diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json index 88cf8742acd..3ab3dae6dcd 100644 --- a/homeassistant/components/deconz/.translations/sv.json +++ b/homeassistant/components/deconz/.translations/sv.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "V\u00e4rd", - "port": "Port (standardv\u00e4rde: '80')" + "port": "Port" }, "title": "Definiera deCONZ-gatewaye" }, diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index c314a1191db..4d3e2cbc6a9 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -11,11 +11,10 @@ from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.util.json import load_json # Loading the config flow file will register the flow from .config_flow import configured_hosts -from .const import CONFIG_FILE, DOMAIN, _LOGGER +from .const import DEFAULT_PORT, DOMAIN, _LOGGER from .gateway import DeconzGateway REQUIREMENTS = ['pydeconz==47'] @@ -27,7 +26,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_API_KEY): cv.string, vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=80): cv.port, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) }, extra=vol.ALLOW_EXTRA) @@ -53,11 +52,7 @@ async def async_setup(hass, config): """ if DOMAIN in config: deconz_config = None - config_file = await hass.async_add_job( - load_json, hass.config.path(CONFIG_FILE)) - if config_file: - deconz_config = config_file - elif CONF_HOST in config[DOMAIN]: + if CONF_HOST in config[DOMAIN]: deconz_config = config[DOMAIN] if deconz_config and not configured_hosts(hass): hass.async_add_job(hass.config_entries.flow.async_init( diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 293b6c1b540..f7bc71a2398 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -6,11 +6,9 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.helpers import aiohttp_client -from homeassistant.util.json import load_json from .const import ( - CONF_ALLOW_DECONZ_GROUPS, CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DOMAIN) - + CONF_ALLOW_DECONZ_GROUPS, CONF_ALLOW_CLIP_SENSOR, DEFAULT_PORT, DOMAIN) CONF_BRIDGEID = 'bridgeid' @@ -35,6 +33,10 @@ class DeconzFlowHandler(config_entries.ConfigFlow): self.deconz_config = {} async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + + async def async_step_init(self, user_input=None): """Handle a deCONZ config flow start. Only allows one instance to be set up. @@ -51,6 +53,8 @@ class DeconzFlowHandler(config_entries.ConfigFlow): if bridge[CONF_HOST] == user_input[CONF_HOST]: self.deconz_config = bridge return await self.async_step_link() + self.deconz_config = user_input + return await self.async_step_link() session = aiohttp_client.async_get_clientsession(self.hass) self.bridges = await async_discovery(session) @@ -58,19 +62,24 @@ class DeconzFlowHandler(config_entries.ConfigFlow): if len(self.bridges) == 1: self.deconz_config = self.bridges[0] return await self.async_step_link() + if len(self.bridges) > 1: hosts = [] for bridge in self.bridges: hosts.append(bridge[CONF_HOST]) return self.async_show_form( - step_id='user', + step_id='init', data_schema=vol.Schema({ vol.Required(CONF_HOST): vol.In(hosts) }) ) - return self.async_abort( - reason='no_bridges' + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + }), ) async def async_step_link(self, user_input=None): @@ -135,13 +144,6 @@ class DeconzFlowHandler(config_entries.ConfigFlow): deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) deconz_config[CONF_BRIDGEID] = discovery_info.get('serial') - config_file = await self.hass.async_add_job( - load_json, self.hass.config.path(CONFIG_FILE)) - if config_file and \ - config_file[CONF_HOST] == deconz_config[CONF_HOST] and \ - CONF_API_KEY in config_file: - deconz_config[CONF_API_KEY] = config_file[CONF_API_KEY] - return await self.async_step_import(deconz_config) async def async_step_import(self, import_config): diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index d856d8c1465..b08f3d71824 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -4,8 +4,8 @@ import logging _LOGGER = logging.getLogger('homeassistant.components.deconz') DOMAIN = 'deconz' -CONFIG_FILE = 'deconz.conf' -DECONZ_DOMAIN = 'deconz' + +DEFAULT_PORT = 80 CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 09549a300a0..9ab7b56c0ca 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -6,7 +6,7 @@ "title": "Define deCONZ gateway", "data": { "host": "Host", - "port": "Port (default value: '80')" + "port": "Port" } }, "link": { diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 20b7a88bc05..9e1d6a2fca1 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for deCONZ config flow.""" -from unittest.mock import patch import pytest import voluptuous as vol @@ -45,7 +44,7 @@ async def test_flow_already_registered_bridge(hass): flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_user() + result = await flow.async_step_init() assert result['type'] == 'abort' @@ -55,8 +54,9 @@ async def test_flow_no_discovered_bridges(hass, aioclient_mock): flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_user() - assert result['type'] == 'abort' + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'user' async def test_flow_one_bridge_discovered(hass, aioclient_mock): @@ -67,7 +67,7 @@ async def test_flow_one_bridge_discovered(hass, aioclient_mock): flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_user() + result = await flow.async_step_init() assert result['type'] == 'form' assert result['step_id'] == 'link' @@ -81,9 +81,9 @@ async def test_flow_two_bridges_discovered(hass, aioclient_mock): flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_user() + result = await flow.async_step_init() assert result['type'] == 'form' - assert result['step_id'] == 'user' + assert result['step_id'] == 'init' with pytest.raises(vol.Invalid): assert result['data_schema']({'host': '0.0.0.0'}) @@ -101,12 +101,26 @@ async def test_flow_two_bridges_selection(hass, aioclient_mock): {'bridgeid': 'id2', 'host': '5.6.7.8', 'port': 80} ] - result = await flow.async_step_user(user_input={'host': '1.2.3.4'}) + result = await flow.async_step_init(user_input={'host': '1.2.3.4'}) assert result['type'] == 'form' assert result['step_id'] == 'link' assert flow.deconz_config['host'] == '1.2.3.4' +async def test_flow_manual_configuration(hass, aioclient_mock): + """Test config flow with manual input.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + user_input = {'host': '1.2.3.4', 'port': 80} + + result = await flow.async_step_init(user_input) + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert flow.deconz_config == user_input + + async def test_link_no_api_key(hass, aioclient_mock): """Test config flow should abort if no API key was possible to retrieve.""" aioclient_mock.post('http://1.2.3.4:80/api', json=[]) @@ -138,57 +152,14 @@ async def test_link_already_registered_bridge(hass): async def test_bridge_discovery(hass): - """Test a bridge being discovered with no additional config file.""" + """Test a bridge being discovered.""" flow = config_flow.DeconzFlowHandler() flow.hass = hass - with patch.object(config_flow, 'load_json', return_value={}): - result = await flow.async_step_discovery({ - 'host': '1.2.3.4', - 'port': 80, - 'serial': 'id' - }) - - assert result['type'] == 'form' - assert result['step_id'] == 'link' - - -async def test_bridge_discovery_config_file(hass): - """Test a bridge being discovered with a corresponding config file.""" - flow = config_flow.DeconzFlowHandler() - flow.hass = hass - with patch.object(config_flow, 'load_json', - return_value={'host': '1.2.3.4', - 'port': 8080, - 'api_key': '1234567890ABCDEF'}): - result = await flow.async_step_discovery({ - 'host': '1.2.3.4', - 'port': 80, - 'serial': 'id' - }) - - assert result['type'] == 'create_entry' - assert result['title'] == 'deCONZ-id' - assert result['data'] == { - 'bridgeid': 'id', + result = await flow.async_step_discovery({ 'host': '1.2.3.4', 'port': 80, - 'api_key': '1234567890ABCDEF', - 'allow_clip_sensor': True, - 'allow_deconz_groups': True - } - - -async def test_bridge_discovery_other_config_file(hass): - """Test a bridge being discovered with another bridges config file.""" - flow = config_flow.DeconzFlowHandler() - flow.hass = hass - with patch.object(config_flow, 'load_json', - return_value={'host': '5.6.7.8', 'api_key': '5678'}): - result = await flow.async_step_discovery({ - 'host': '1.2.3.4', - 'port': 80, - 'serial': 'id' - }) + 'serial': 'id' + }) assert result['type'] == 'form' assert result['step_id'] == 'link' diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py new file mode 100644 index 00000000000..3411f96b981 --- /dev/null +++ b/tests/components/deconz/test_gateway.py @@ -0,0 +1,222 @@ +"""Test deCONZ gateway.""" +from unittest.mock import Mock, patch + +from homeassistant.components.deconz import gateway + +from tests.common import mock_coro + +ENTRY_CONFIG = { + "host": "1.2.3.4", + "port": 80, + "api_key": "1234567890ABCDEF", + "bridgeid": "0123456789ABCDEF", + "allow_clip_sensor": True, + "allow_deconz_groups": True, +} + + +async def test_gateway_setup(): + """Successful setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + api = Mock() + api.async_add_remote.return_value = Mock() + api.sensors = {} + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', return_value=mock_coro(api)), \ + patch.object( + gateway, 'async_dispatcher_connect', return_value=Mock()): + assert await deconz_gateway.async_setup() is True + + assert deconz_gateway.api is api + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'binary_sensor') + assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ + (entry, 'cover') + assert hass.config_entries.async_forward_entry_setup.mock_calls[2][1] == \ + (entry, 'light') + assert hass.config_entries.async_forward_entry_setup.mock_calls[3][1] == \ + (entry, 'scene') + assert hass.config_entries.async_forward_entry_setup.mock_calls[4][1] == \ + (entry, 'sensor') + assert hass.config_entries.async_forward_entry_setup.mock_calls[5][1] == \ + (entry, 'switch') + assert len(api.start.mock_calls) == 1 + + +async def test_gateway_retry(): + """Retry setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', return_value=mock_coro(False)): + assert await deconz_gateway.async_setup() is False + + +async def test_connection_status(hass): + """Make sure that connection status triggers a dispatcher send.""" + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + with patch.object(gateway, 'async_dispatcher_send') as mock_dispatch_send: + deconz_gateway.async_connection_status_callback(True) + + await hass.async_block_till_done() + assert len(mock_dispatch_send.mock_calls) == 1 + assert len(mock_dispatch_send.mock_calls[0]) == 3 + + +async def test_add_device(hass): + """Successful retry setup.""" + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + with patch.object(gateway, 'async_dispatcher_send') as mock_dispatch_send: + deconz_gateway.async_add_device_callback('sensor', Mock()) + + await hass.async_block_till_done() + assert len(mock_dispatch_send.mock_calls) == 1 + assert len(mock_dispatch_send.mock_calls[0]) == 3 + + +async def test_add_remote(): + """Successful add remote.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + remote = Mock() + remote.name = 'name' + remote.type = 'ZHASwitch' + remote.register_async_callback = Mock() + + deconz_gateway = gateway.DeconzGateway(hass, entry) + deconz_gateway.async_add_remote([remote]) + + assert len(deconz_gateway.events) == 1 + + +async def test_shutdown(): + """Successful shutdown.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + deconz_gateway.api = Mock() + deconz_gateway.shutdown(None) + + assert len(deconz_gateway.api.close.mock_calls) == 1 + + +async def test_reset_cancel_retry(): + """Verify async reset can handle a scheduled retry.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', return_value=mock_coro(False)): + assert await deconz_gateway.async_setup() is False + + assert deconz_gateway._cancel_retry_setup is not None + + assert await deconz_gateway.async_reset() is True + + +async def test_reset_after_successful_setup(): + """Verify that reset works on a setup component.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + api = Mock() + api.async_add_remote.return_value = Mock() + api.sensors = {} + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', return_value=mock_coro(api)), \ + patch.object( + gateway, 'async_dispatcher_connect', return_value=Mock()): + assert await deconz_gateway.async_setup() is True + + listener = Mock() + deconz_gateway.listeners = [listener] + event = Mock() + event.async_will_remove_from_hass = Mock() + deconz_gateway.events = [event] + deconz_gateway.deconz_ids = {'key': 'value'} + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + assert await deconz_gateway.async_reset() is True + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 6 + + assert len(listener.mock_calls) == 1 + assert len(deconz_gateway.listeners) == 0 + + assert len(event.async_will_remove_from_hass.mock_calls) == 1 + assert len(deconz_gateway.events) == 0 + + assert len(deconz_gateway.deconz_ids) == 0 + + +async def test_get_gateway(hass): + """Successful call.""" + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(True)): + assert await gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) + + +async def test_get_gateway_fails(hass): + """Failed call.""" + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(False)): + assert await gateway.get_gateway( + hass, ENTRY_CONFIG, Mock(), Mock()) is False + + +async def test_create_event(): + """Successfully created a deCONZ event.""" + hass = Mock() + remote = Mock() + remote.name = 'Name' + + event = gateway.DeconzEvent(hass, remote) + + assert event._id == 'name' + + +async def test_update_event(): + """Successfully update a deCONZ event.""" + hass = Mock() + remote = Mock() + remote.name = 'Name' + + event = gateway.DeconzEvent(hass, remote) + event.async_update_callback({'state': True}) + + assert len(hass.bus.async_fire.mock_calls) == 1 + + +async def test_remove_event(): + """Successfully update a deCONZ event.""" + hass = Mock() + remote = Mock() + remote.name = 'Name' + + event = gateway.DeconzEvent(hass, remote) + event.async_will_remove_from_hass() + + assert event._device is None diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 3453dd86c12..b83756f6ebb 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -21,8 +21,7 @@ CONFIG = { async def test_config_with_host_passed_to_config_entry(hass): """Test that configured options for a host are loaded via config entry.""" with patch.object(hass, 'config_entries') as mock_config_entries, \ - patch.object(deconz, 'configured_hosts', return_value=[]), \ - patch.object(deconz, 'load_json', return_value={}): + patch.object(deconz, 'configured_hosts', return_value=[]): assert await async_setup_component(hass, deconz.DOMAIN, { deconz.DOMAIN: { deconz.CONF_HOST: '1.2.3.4', @@ -33,24 +32,10 @@ async def test_config_with_host_passed_to_config_entry(hass): assert len(mock_config_entries.flow.mock_calls) == 2 -async def test_config_file_passed_to_config_entry(hass): - """Test that configuration file for a host are loaded via config entry.""" - with patch.object(hass, 'config_entries') as mock_config_entries, \ - patch.object(deconz, 'configured_hosts', return_value=[]), \ - patch.object(deconz, 'load_json', - return_value={'host': '1.2.3.4'}): - assert await async_setup_component(hass, deconz.DOMAIN, { - deconz.DOMAIN: {} - }) is True - # Import flow started - assert len(mock_config_entries.flow.mock_calls) == 2 - - async def test_config_without_host_not_passed_to_config_entry(hass): """Test that a configuration without a host does not initiate an import.""" with patch.object(hass, 'config_entries') as mock_config_entries, \ - patch.object(deconz, 'configured_hosts', return_value=[]), \ - patch.object(deconz, 'load_json', return_value={}): + patch.object(deconz, 'configured_hosts', return_value=[]): assert await async_setup_component(hass, deconz.DOMAIN, { deconz.DOMAIN: {} }) is True @@ -62,8 +47,7 @@ async def test_config_already_registered_not_passed_to_config_entry(hass): """Test that an already registered host does not initiate an import.""" with patch.object(hass, 'config_entries') as mock_config_entries, \ patch.object(deconz, 'configured_hosts', - return_value=['1.2.3.4']), \ - patch.object(deconz, 'load_json', return_value={}): + return_value=['1.2.3.4']): assert await async_setup_component(hass, deconz.DOMAIN, { deconz.DOMAIN: { deconz.CONF_HOST: '1.2.3.4', From ddee5f8b86567fcf7c5868faa83bc1d647156925 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 6 Nov 2018 04:36:52 -0500 Subject: [PATCH 045/238] Fix IOLinc sensor (#18250) --- homeassistant/components/binary_sensor/insteon.py | 3 ++- homeassistant/components/insteon/__init__.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/binary_sensor/insteon.py b/homeassistant/components/binary_sensor/insteon.py index c399d31a95b..009de676bf3 100644 --- a/homeassistant/components/binary_sensor/insteon.py +++ b/homeassistant/components/binary_sensor/insteon.py @@ -57,7 +57,8 @@ class InsteonBinarySensor(InsteonEntity, BinarySensorDevice): """Return the boolean response if the node is on.""" on_val = bool(self._insteon_device_state.value) - if self._insteon_device_state.name == 'lightSensor': + if self._insteon_device_state.name in ['lightSensor', + 'openClosedSensor']: return not on_val return on_val diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 3980503a1ac..14d43cbcaee 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.15.0'] +REQUIREMENTS = ['insteonplm==0.15.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7157f02875d..97490780dae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -513,7 +513,7 @@ ihcsdk==2.2.0 influxdb==5.0.0 # homeassistant.components.insteon -insteonplm==0.15.0 +insteonplm==0.15.1 # homeassistant.components.sensor.iperf3 iperf3==0.1.10 From 2bf2214d5151178738898f286dab3648b96f1691 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Tue, 6 Nov 2018 03:39:10 -0600 Subject: [PATCH 046/238] Add support for locks in google assistant component (#18233) * Add support for locks in google assistant component This is supported by the smarthome API, but there is no documentation for it. This work is based on an article I found with screenshots of documentation that was erroneously uploaded: https://www.androidpolice.com/2018/01/17/google-assistant-home-can-now-natively-control-smart-locks-august-vivint-first-supported/ Google Assistant now supports unlocking certain locks - Nest and August come to mind - via this API, and this commit allows Home Assistant to do so as well. Notably, I've added a config option `allow_unlock` that controls whether we actually honor requests to unlock a lock via the google assistant. It defaults to false. Additionally, we add the functionNotSupported error, which makes a little more sense when we're unable to execute the desired state transition. https://developers.google.com/actions/reference/smarthome/errors-exceptions#exception_list * Fix linter warnings * Ensure that certain groups are never exposed to cloud entities For example, the group.all_locks entity - we should probably never expose this to third party cloud integrations. It's risky. This is not configurable, but can be extended by adding to the cloud.const.NEVER_EXPOSED_ENTITIES array. It's implemented in a modestly hacky fashion, because we determine whether or not a entity should be excluded/included in several ways. Notably, we define this array in the top level const.py, to avoid circular import problems between the cloud/alexa components. --- homeassistant/components/alexa/smart_home.py | 11 +- homeassistant/components/cloud/__init__.py | 11 +- .../components/google_assistant/__init__.py | 7 +- .../components/google_assistant/const.py | 6 +- .../components/google_assistant/helpers.py | 4 +- .../components/google_assistant/http.py | 4 + .../components/google_assistant/smart_home.py | 18 ++- .../components/google_assistant/trait.py | 49 +++++- homeassistant/const.py | 4 + tests/components/alexa/test_smart_home.py | 26 ++++ tests/components/cloud/test_iot.py | 2 + tests/components/google_assistant/__init__.py | 24 +++ .../google_assistant/test_google_assistant.py | 13 +- .../components/google_assistant/test_trait.py | 147 ++++++++++++++---- 14 files changed, 283 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 6b747689057..80e584a4a75 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -16,9 +16,9 @@ from homeassistant.components import ( input_boolean, light, lock, media_player, scene, script, sensor, switch) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, SERVICE_LOCK, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, - SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, 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, STATE_LOCKED, STATE_ON, STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -1194,6 +1194,11 @@ async def async_api_discovery(hass, config, directive, context): discovery_endpoints = [] for entity in hass.states.async_all(): + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + _LOGGER.debug("Not exposing %s because it is never exposed", + entity.entity_id) + continue + if not config.should_expose(entity.entity_id): _LOGGER.debug("Not exposing %s because filtered by config", entity.entity_id) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index bc486eb7ead..d9ee2a62b84 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -12,7 +12,8 @@ import os import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME) + EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION, + CONF_MODE, CONF_NAME) from homeassistant.helpers import entityfilter, config_validation as cv from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh @@ -68,7 +69,9 @@ ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ }) GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ - vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA} + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}, + vol.Optional(ga_c.CONF_ALLOW_UNLOCK, + default=ga_c.DEFAULT_ALLOW_UNLOCK): cv.boolean }) CONFIG_SCHEMA = vol.Schema({ @@ -184,12 +187,16 @@ class Cloud: def should_expose(entity): """If an entity should be exposed.""" + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return conf['filter'](entity.entity_id) self._gactions_config = ga_h.Config( should_expose=should_expose, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), + allow_unlock=conf.get(ga_c.CONF_ALLOW_UNLOCK), ) return self._gactions_config diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 8d4ac9f01c9..f444974bc8d 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -24,7 +24,8 @@ from .const import ( DOMAIN, CONF_PROJECT_ID, CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_API_KEY, SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, - CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT + CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT, CONF_ALLOW_UNLOCK, + DEFAULT_ALLOW_UNLOCK ) from .http import async_register_http @@ -48,7 +49,9 @@ GOOGLE_ASSISTANT_SCHEMA = vol.Schema({ vol.Optional(CONF_EXPOSED_DOMAINS, default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, vol.Optional(CONF_API_KEY): cv.string, - vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA} + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}, + vol.Optional(CONF_ALLOW_UNLOCK, + default=DEFAULT_ALLOW_UNLOCK): cv.boolean }, extra=vol.PREVENT_EXTRA) CONFIG_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 2f54ee33f77..aca960f9c0a 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -11,12 +11,14 @@ CONF_PROJECT_ID = 'project_id' CONF_ALIASES = 'aliases' CONF_API_KEY = 'api_key' CONF_ROOM_HINT = 'room' +CONF_ALLOW_UNLOCK = 'allow_unlock' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ 'climate', 'cover', 'fan', 'group', 'input_boolean', 'light', - 'media_player', 'scene', 'script', 'switch', 'vacuum', + 'media_player', 'scene', 'script', 'switch', 'vacuum', 'lock', ] +DEFAULT_ALLOW_UNLOCK = False CLIMATE_MODE_HEATCOOL = 'heatcool' CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} @@ -27,6 +29,7 @@ TYPE_VACUUM = PREFIX_TYPES + 'VACUUM' TYPE_SCENE = PREFIX_TYPES + 'SCENE' TYPE_FAN = PREFIX_TYPES + 'FAN' TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT' +TYPE_LOCK = PREFIX_TYPES + 'LOCK' SERVICE_REQUEST_SYNC = 'request_sync' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' @@ -40,3 +43,4 @@ ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange" ERR_NOT_SUPPORTED = "notSupported" ERR_PROTOCOL_ERROR = 'protocolError' ERR_UNKNOWN_ERROR = 'unknownError' +ERR_FUNCTION_NOT_SUPPORTED = 'functionNotSupported' diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index ef6ae109eb5..e71756d9fee 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -16,8 +16,10 @@ class SmartHomeError(Exception): class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, agent_user_id, entity_config=None): + def __init__(self, should_expose, agent_user_id, entity_config=None, + allow_unlock=False): """Initialize the configuration.""" self.should_expose = should_expose self.agent_user_id = agent_user_id self.entity_config = entity_config or {} + self.allow_unlock = allow_unlock diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 65af7b932b0..a6b4633e762 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -11,6 +11,7 @@ from aiohttp.web import Request, Response # Typing imports from homeassistant.components.http import HomeAssistantView from homeassistant.core import callback +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, @@ -38,6 +39,9 @@ def async_register_http(hass, cfg): # Ignore entities that are views return False + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + explicit_expose = \ entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 633e6258c03..bab63bdb7ae 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -7,7 +7,9 @@ from homeassistant.util.decorator import Registry from homeassistant.core import callback from homeassistant.const import ( - CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES) + CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, STATE_UNAVAILABLE, + ATTR_SUPPORTED_FEATURES +) from homeassistant.components import ( climate, cover, @@ -15,6 +17,7 @@ from homeassistant.components import ( group, input_boolean, light, + lock, media_player, scene, script, @@ -22,12 +25,13 @@ from homeassistant.components import ( vacuum, ) + from . import trait from .const import ( - TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM, + TYPE_LIGHT, TYPE_LOCK, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM, TYPE_THERMOSTAT, TYPE_FAN, CONF_ALIASES, CONF_ROOM_HINT, - ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, + ERR_FUNCTION_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR ) from .helpers import SmartHomeError @@ -42,6 +46,7 @@ DOMAIN_TO_GOOGLE_TYPES = { group.DOMAIN: TYPE_SWITCH, input_boolean.DOMAIN: TYPE_SWITCH, light.DOMAIN: TYPE_LIGHT, + lock.DOMAIN: TYPE_LOCK, media_player.DOMAIN: TYPE_SWITCH, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, @@ -80,7 +85,7 @@ class _GoogleEntity: domain = state.domain features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - return [Trait(self.hass, state) for Trait in trait.TRAITS + return [Trait(self.hass, state, self.config) for Trait in trait.TRAITS if Trait.supported(domain, features)] @callback @@ -168,7 +173,7 @@ class _GoogleEntity: if not executed: raise SmartHomeError( - ERR_NOT_SUPPORTED, + ERR_FUNCTION_NOT_SUPPORTED, 'Unable to execute {} for {}'.format(command, self.state.entity_id)) @@ -232,6 +237,9 @@ async def async_devices_sync(hass, config, payload): """ devices = [] for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + if not config.should_expose(state): continue diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 00a01f262a9..ce13818d9de 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -10,6 +10,7 @@ from homeassistant.components import ( input_boolean, media_player, light, + lock, scene, script, switch, @@ -19,6 +20,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_LOCKED, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -40,6 +42,7 @@ TRAIT_COLOR_SPECTRUM = PREFIX_TRAITS + 'ColorSpectrum' TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' TRAIT_SCENE = PREFIX_TRAITS + 'Scene' TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' +TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock' PREFIX_COMMANDS = 'action.devices.commands.' COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' @@ -54,6 +57,7 @@ COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' +COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock' TRAITS = [] @@ -77,10 +81,11 @@ class _Trait: commands = [] - def __init__(self, hass, state): + def __init__(self, hass, state, config): """Initialize a trait for a state.""" self.hass = hass self.state = state + self.config = config def sync_attributes(self): """Return attributes for a sync request.""" @@ -628,3 +633,45 @@ class TemperatureSettingTrait(_Trait): climate.ATTR_OPERATION_MODE: self.google_to_hass[params['thermostatMode']], }, blocking=True) + + +@register_trait +class LockUnlockTrait(_Trait): + """Trait to lock or unlock a lock. + + https://developers.google.com/actions/smarthome/traits/lockunlock + """ + + name = TRAIT_LOCKUNLOCK + commands = [ + COMMAND_LOCKUNLOCK + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain == lock.DOMAIN + + def sync_attributes(self): + """Return LockUnlock attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return LockUnlock query attributes.""" + return {'isLocked': self.state.state == STATE_LOCKED} + + def can_execute(self, command, params): + """Test if command can be executed.""" + allowed_unlock = not params['lock'] and self.config.allow_unlock + return params['lock'] or allowed_unlock + + async def execute(self, command, params): + """Execute an LockUnlock command.""" + if params['lock']: + service = lock.SERVICE_LOCK + else: + service = lock.SERVICE_UNLOCK + + await self.hass.services.async_call(lock.DOMAIN, service, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=True) diff --git a/homeassistant/const.py b/homeassistant/const.py index ffbba575a14..50a27f9d9c8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -449,3 +449,7 @@ WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] PRECISION_WHOLE = 1 PRECISION_HALVES = 0.5 PRECISION_TENTHS = 0.1 + +# Static list of entities that will never be exposed to +# cloud, alexa, or google_home components +CLOUD_NEVER_EXPOSED_ENTITIES = ['group.all_locks'] diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 186a35c19ec..4ea06b57a38 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -987,6 +987,32 @@ async def test_include_filters(hass): assert len(msg['payload']['endpoints']) == 3 +async def test_never_exposed_entities(hass): + """Test never exposed locks do not get discovered.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + # setup test devices + hass.states.async_set( + 'group.all_locks', 'on', {'friendly_name': "Blocked locks"}) + + hass.states.async_set( + 'group.allow', 'off', {'friendly_name': "Allowed group"}) + + config = smart_home.Config(should_expose=entityfilter.generate_filter( + include_domains=['group'], + include_entities=[], + exclude_domains=[], + exclude_entities=[], + )) + + msg = await smart_home.async_handle_message(hass, config, request) + await hass.async_block_till_done() + + msg = msg['event'] + + assert len(msg['payload']['endpoints']) == 1 + + async def test_api_entity_not_exists(hass): """Test api turn on process without entity.""" request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test') diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 07ec1851fbe..d0b145c1b67 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -326,6 +326,8 @@ def test_handler_google_actions(hass): 'switch.test', 'on', {'friendly_name': "Test switch"}) hass.states.async_set( 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + hass.states.async_set( + 'group.all_locks', 'on', {'friendly_name': "Evil locks"}) with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 273a7e86505..5fd00abc411 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -230,4 +230,28 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.TemperatureSetting'], 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False +}, { + 'id': 'lock.front_door', + 'name': { + 'name': 'Front Door' + }, + 'traits': ['action.devices.traits.LockUnlock'], + 'type': 'action.devices.types.LOCK', + 'willReportState': False +}, { + 'id': 'lock.kitchen_door', + 'name': { + 'name': 'Kitchen Door' + }, + 'traits': ['action.devices.traits.LockUnlock'], + 'type': 'action.devices.types.LOCK', + 'willReportState': False +}, { + 'id': 'lock.openable_lock', + 'name': { + 'name': 'Openable Lock' + }, + 'traits': ['action.devices.traits.LockUnlock'], + 'type': 'action.devices.types.LOCK', + 'willReportState': False }] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 2ebfa5cc9ed..047fad3574c 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -8,7 +8,8 @@ import pytest from homeassistant import core, const, setup from homeassistant.components import ( - fan, cover, light, switch, climate, async_setup, media_player) + fan, cover, light, switch, climate, lock, async_setup, media_player) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.components import google_assistant as ga from . import DEMO_DEVICES @@ -96,6 +97,13 @@ def hass_fixture(loop, hass): }] })) + loop.run_until_complete( + setup.async_setup_component(hass, lock.DOMAIN, { + 'lock': [{ + 'platform': 'demo' + }] + })) + return hass @@ -116,6 +124,9 @@ def test_sync_request(hass_fixture, assistant_client, auth_header): sorted([dev['id'] for dev in devices]) == sorted([dev['id'] for dev in DEMO_DEVICES])) + for dev in devices: + assert dev['id'] not in CLOUD_NEVER_EXPOSED_ENTITIES + for dev, demo in zip( sorted(devices, key=lambda d: d['id']), sorted(DEMO_DEVICES, key=lambda d: d['id'])): diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index a347b6c6fc0..ff3ce65ee27 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -11,6 +11,7 @@ from homeassistant.components import ( fan, input_boolean, light, + lock, media_player, scene, script, @@ -23,6 +24,17 @@ from homeassistant.util import color from tests.common import async_mock_service +BASIC_CONFIG = helpers.Config( + should_expose=lambda state: True, + agent_user_id='test-agent', +) + +UNSAFE_CONFIG = helpers.Config( + should_expose=lambda state: True, + agent_user_id='test-agent', + allow_unlock=True, +) + async def test_brightness_light(hass): """Test brightness trait support for light domain.""" @@ -31,7 +43,7 @@ async def test_brightness_light(hass): trt = trait.BrightnessTrait(hass, State('light.bla', light.STATE_ON, { light.ATTR_BRIGHTNESS: 243 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == {} @@ -57,7 +69,7 @@ async def test_brightness_cover(hass): trt = trait.BrightnessTrait(hass, State('cover.bla', cover.STATE_OPEN, { cover.ATTR_CURRENT_POSITION: 75 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == {} @@ -85,7 +97,7 @@ async def test_brightness_media_player(hass): trt = trait.BrightnessTrait(hass, State( 'media_player.bla', media_player.STATE_PLAYING, { media_player.ATTR_MEDIA_VOLUME_LEVEL: .3 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == {} @@ -109,7 +121,7 @@ async def test_onoff_group(hass): """Test OnOff trait support for group domain.""" assert trait.OnOffTrait.supported(group.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON), BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -117,7 +129,9 @@ async def test_onoff_group(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('group.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('group.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -145,7 +159,8 @@ async def test_onoff_input_boolean(hass): """Test OnOff trait support for input_boolean domain.""" assert trait.OnOffTrait.supported(input_boolean.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON), + BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -153,7 +168,9 @@ async def test_onoff_input_boolean(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -182,7 +199,8 @@ async def test_onoff_switch(hass): """Test OnOff trait support for switch domain.""" assert trait.OnOffTrait.supported(switch.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON), + BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -190,7 +208,9 @@ async def test_onoff_switch(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('switch.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('switch.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -218,7 +238,7 @@ async def test_onoff_fan(hass): """Test OnOff trait support for fan domain.""" assert trait.OnOffTrait.supported(fan.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON), BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -226,7 +246,7 @@ async def test_onoff_fan(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('fan.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('fan.bla', STATE_OFF), BASIC_CONFIG) assert trt_off.query_attributes() == { 'on': False } @@ -254,7 +274,7 @@ async def test_onoff_light(hass): """Test OnOff trait support for light domain.""" assert trait.OnOffTrait.supported(light.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON), BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -262,7 +282,9 @@ async def test_onoff_light(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('light.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('light.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -290,7 +312,8 @@ async def test_onoff_cover(hass): """Test OnOff trait support for cover domain.""" assert trait.OnOffTrait.supported(cover.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_OPEN)) + trt_on = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_OPEN), + BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -298,7 +321,9 @@ async def test_onoff_cover(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_CLOSED)) + trt_off = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_CLOSED), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -327,7 +352,8 @@ async def test_onoff_media_player(hass): """Test OnOff trait support for media_player domain.""" assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON), + BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -335,7 +361,9 @@ async def test_onoff_media_player(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('media_player.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('media_player.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -349,7 +377,9 @@ async def test_onoff_media_player(hass): ATTR_ENTITY_ID: 'media_player.bla', } - off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF) + off_calls = async_mock_service(hass, media_player.DOMAIN, + SERVICE_TURN_OFF) + await trt_on.execute(trait.COMMAND_ONOFF, { 'on': False }) @@ -363,7 +393,8 @@ async def test_dock_vacuum(hass): """Test dock trait support for vacuum domain.""" assert trait.DockTrait.supported(vacuum.DOMAIN, 0) - trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE)) + trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE), + BASIC_CONFIG) assert trt.sync_attributes() == {} @@ -386,7 +417,7 @@ async def test_startstop_vacuum(hass): trt = trait.StartStopTrait(hass, State('vacuum.bla', vacuum.STATE_PAUSED, { ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_PAUSE, - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == {'pausable': True} @@ -436,7 +467,7 @@ async def test_color_spectrum_light(hass): trt = trait.ColorSpectrumTrait(hass, State('light.bla', STATE_ON, { light.ATTR_HS_COLOR: (0, 94), - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == { 'colorModel': 'rgb' @@ -482,7 +513,7 @@ async def test_color_temperature_light(hass): light.ATTR_MIN_MIREDS: 200, light.ATTR_COLOR_TEMP: 300, light.ATTR_MAX_MIREDS: 500, - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == { 'temperatureMinK': 2000, @@ -538,7 +569,7 @@ async def test_color_temperature_light_bad_temp(hass): light.ATTR_MIN_MIREDS: 200, light.ATTR_COLOR_TEMP: 0, light.ATTR_MAX_MIREDS: 500, - })) + }), BASIC_CONFIG) assert trt.query_attributes() == { } @@ -548,7 +579,7 @@ async def test_scene_scene(hass): """Test Scene trait support for scene domain.""" assert trait.SceneTrait.supported(scene.DOMAIN, 0) - trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE)) + trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE), BASIC_CONFIG) assert trt.sync_attributes() == {} assert trt.query_attributes() == {} assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) @@ -565,7 +596,7 @@ async def test_scene_script(hass): """Test Scene trait support for script domain.""" assert trait.SceneTrait.supported(script.DOMAIN, 0) - trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF)) + trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF), BASIC_CONFIG) assert trt.sync_attributes() == {} assert trt.query_attributes() == {} assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) @@ -605,7 +636,7 @@ async def test_temperature_setting_climate_range(hass): climate.ATTR_TARGET_TEMP_LOW: 65, climate.ATTR_MIN_TEMP: 50, climate.ATTR_MAX_TEMP: 80 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == { 'availableThermostatModes': 'off,cool,heat,heatcool', 'thermostatTemperatureUnit': 'F', @@ -672,7 +703,7 @@ async def test_temperature_setting_climate_setpoint(hass): climate.ATTR_MAX_TEMP: 30, climate.ATTR_TEMPERATURE: 18, climate.ATTR_CURRENT_TEMPERATURE: 20 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == { 'availableThermostatModes': 'off,cool', 'thermostatTemperatureUnit': 'C', @@ -702,3 +733,65 @@ async def test_temperature_setting_climate_setpoint(hass): ATTR_ENTITY_ID: 'climate.bla', climate.ATTR_TEMPERATURE: 19 } + + +async def test_lock_unlock_lock(hass): + """Test LockUnlock trait locking support for lock domain.""" + assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN) + + trt = trait.LockUnlockTrait(hass, + State('lock.front_door', lock.STATE_UNLOCKED), + BASIC_CONFIG) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'isLocked': False + } + + assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) + + calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK) + await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) + + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'lock.front_door' + } + + +async def test_lock_unlock_unlock(hass): + """Test LockUnlock trait unlocking support for lock domain.""" + assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN) + + trt = trait.LockUnlockTrait(hass, + State('lock.front_door', lock.STATE_LOCKED), + BASIC_CONFIG) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'isLocked': True + } + + assert not trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) + + trt = trait.LockUnlockTrait(hass, + State('lock.front_door', lock.STATE_LOCKED), + UNSAFE_CONFIG) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'isLocked': True + } + + assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) + + calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK) + await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) + + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'lock.front_door' + } From 087bffeaae8738692d1c0f4ed2d09f484193bc26 Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Tue, 6 Nov 2018 05:00:48 -0500 Subject: [PATCH 047/238] Add workaround to use notification state for zwave lock state (#17386) * Add workaround to use notification state for zwave lock state There are several zwave lock models out there which do not seem to update the lock state on non-rf events (see #11934 #14632 #14534 for examples) including kwikset smartkey zwave plus locks (which I own). In these cases it seems that the notifications for non-rf events the access_control value is updated but not the primary value for the lock state, which is what is used to set the is_locked property. To properly have the lock state accurate for all types of notifications on these models we need to use the access_control field. This commit adds a workaround for the 4 models reported to exhibit this behavior so that home-assistant will reliably set the lock state for all device notifications. * Add YRD220 as per adrum to workaround list * Inline constants --- homeassistant/components/lock/zwave.py | 24 ++++++++++++++++++++++-- tests/components/lock/test_zwave.py | 16 ++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 4a2b71f0b48..2ea8300fb9a 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -28,9 +28,22 @@ POLYCONTROL = 0x10E DANALOCK_V2_BTZE = 0x2 POLYCONTROL_DANALOCK_V2_BTZE_LOCK = (POLYCONTROL, DANALOCK_V2_BTZE) WORKAROUND_V2BTZE = 'v2btze' +WORKAROUND_DEVICE_STATE = 'state' DEVICE_MAPPINGS = { - POLYCONTROL_DANALOCK_V2_BTZE_LOCK: WORKAROUND_V2BTZE + POLYCONTROL_DANALOCK_V2_BTZE_LOCK: WORKAROUND_V2BTZE, + # Kwikset 914TRL ZW500 + (0x0090, 0x440): WORKAROUND_DEVICE_STATE, + # Yale YRD210 + (0x0129, 0x0209): WORKAROUND_DEVICE_STATE, + (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE, + (0x0129, 0x0000): WORKAROUND_DEVICE_STATE, + # Yale YRD220 (as reported by adrum in PR #17386) + (0x0109, 0x0000): WORKAROUND_DEVICE_STATE, + # Schlage BE469 + (0x003B, 0x5044): WORKAROUND_DEVICE_STATE, + # Schlage FE599NX + (0x003B, 0x504C): WORKAROUND_DEVICE_STATE, } LOCK_NOTIFICATION = { @@ -204,6 +217,7 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): self._notification = None self._lock_status = None self._v2btze = None + self._state_workaround = False # Enable appropriate workaround flags for our device # Make sure that we have values for the key before converting to int @@ -216,6 +230,11 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): self._v2btze = 1 _LOGGER.debug("Polycontrol Danalock v2 BTZE " "workaround enabled") + if DEVICE_MAPPINGS[specific_sensor_key] == \ + WORKAROUND_DEVICE_STATE: + self._state_workaround = True + _LOGGER.debug( + "Notification device state workaround enabled") self.update_properties() def update_properties(self): @@ -225,7 +244,8 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): if self.values.access_control: notification_data = self.values.access_control.data self._notification = LOCK_NOTIFICATION.get(str(notification_data)) - + if self._state_workaround: + self._state = LOCK_STATUS.get(str(notification_data)) if self._v2btze: if self.values.v2btze_advanced and \ self.values.v2btze_advanced.data == CONFIG_ADVANCED: diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index 83aec7f0ce9..89ce034d445 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -63,6 +63,22 @@ def test_lock_value_changed(mock_openzwave): assert device.is_locked +def test_lock_value_changed_workaround(mock_openzwave): + """Test value changed for Z-Wave lock using notification state.""" + node = MockNode(manufacturer_id='0090', product_id='0440') + values = MockEntityValues( + primary=MockValue(data=True, node=node), + access_control=MockValue(data=1, node=node), + alarm_type=None, + alarm_level=None, + ) + device = zwave.get_device(node=node, values=values) + assert device.is_locked + values.access_control.data = 2 + value_changed(values.access_control) + assert not device.is_locked + + def test_v2btze_value_changed(mock_openzwave): """Test value changed for v2btze Z-Wave lock.""" node = MockNode(manufacturer_id='010e', product_id='0002') From 121ec5c684e43d82203d984979827015bce0c689 Mon Sep 17 00:00:00 2001 From: Jorim Tielemans Date: Tue, 6 Nov 2018 12:34:11 +0100 Subject: [PATCH 048/238] Add season icons (#18221) * Add season icons * Use STATE constants * Calm down hound * Update season.py --- homeassistant/components/sensor/season.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py index 3b14c3854cd..c9b222f2b26 100644 --- a/homeassistant/components/sensor/season.py +++ b/homeassistant/components/sensor/season.py @@ -34,6 +34,13 @@ HEMISPHERE_SEASON_SWAP = {STATE_WINTER: STATE_SUMMER, STATE_AUTUMN: STATE_SPRING, STATE_SUMMER: STATE_WINTER} +SEASON_ICONS = { + STATE_SPRING: 'mdi:flower', + STATE_SUMMER: 'mdi:sunglasses', + STATE_AUTUMN: 'mdi:leaf', + STATE_WINTER: 'mdi:snowflake' +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES) @@ -116,6 +123,11 @@ class Season(Entity): """Return the current season.""" return self.season + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SEASON_ICONS.get(self.season, 'mdi:cloud') + def update(self): """Update season.""" self.datetime = datetime.now() From b506aafbb4fbbcb8fcdc0b929827d3a66a308308 Mon Sep 17 00:00:00 2001 From: Matthias Urlichs Date: Tue, 6 Nov 2018 12:41:15 +0100 Subject: [PATCH 049/238] docstring fix (#18257) --- homeassistant/helpers/discovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 405861eeb75..34f9a95b3a4 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -119,7 +119,7 @@ def load_platform(hass, component, platform, discovered, hass_config): Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be fired to load the platform. The event will contain: - { ATTR_SERVICE = LOAD_PLATFORM + '.' + <> + { ATTR_SERVICE = EVENT_LOAD_PLATFORM + '.' + <> ATTR_PLATFORM = <> ATTR_DISCOVERED = <> } @@ -137,7 +137,7 @@ async def async_load_platform(hass, component, platform, discovered, Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be fired to load the platform. The event will contain: - { ATTR_SERVICE = LOAD_PLATFORM + '.' + <> + { ATTR_SERVICE = EVENT_LOAD_PLATFORM + '.' + <> ATTR_PLATFORM = <> ATTR_DISCOVERED = <> } From 4581a741bdc22f6085c78bfbfe9c243d1b20a4d4 Mon Sep 17 00:00:00 2001 From: Matthias Urlichs Date: Tue, 6 Nov 2018 12:41:39 +0100 Subject: [PATCH 050/238] Report *which* component didn't return a bool (#18258) * Report *which* component didn't return a bool * break over-long line --- homeassistant/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 41201264da2..057843834c0 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -160,8 +160,8 @@ async def _async_setup_component(hass: core.HomeAssistant, log_error("Component failed to initialize.") return False if result is not True: - log_error("Component did not return boolean if setup was successful. " - "Disabling component.") + log_error("Component {!r} did not return boolean if setup was " + "successful. Disabling component.".format(domain)) loader.set_component(hass, domain, None) return False From 3322fee8147ae136cbe477e664173fcf922e18b8 Mon Sep 17 00:00:00 2001 From: akloeckner Date: Tue, 6 Nov 2018 12:43:16 +0100 Subject: [PATCH 051/238] Fritz keepalive (#18155) * Add keepalive support - adds keepalive support - adds debug messages - corrects CONF_PHONEBOOK and CONF_PREFIXES constants * Add keepalive config constant * Fix default value * More visual indentation * move to platform * Move to platform * Make keepalive default and remove option * Forgot a few lines --- homeassistant/components/sensor/fritzbox_callmonitor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index 317416a15b8..397f08d8a7c 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -63,8 +63,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): port = config.get(CONF_PORT) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - phonebook_id = config.get('phonebook') - prefixes = config.get('prefixes') + phonebook_id = config.get(CONF_PHONEBOOK) + prefixes = config.get(CONF_PREFIXES) try: phonebook = FritzBoxPhonebook( @@ -156,8 +156,10 @@ class FritzBoxCallMonitor: def connect(self): """Connect to the Fritz!Box.""" + _LOGGER.debug('Setting up socket...') self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(10) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) try: self.sock.connect((self.host, self.port)) threading.Thread(target=self._listen).start() @@ -168,6 +170,7 @@ class FritzBoxCallMonitor: def _listen(self): """Listen to incoming or outgoing calls.""" + _LOGGER.debug('Connection established, waiting for response...') while not self.stopped.isSet(): try: response = self.sock.recv(2048) @@ -175,10 +178,12 @@ class FritzBoxCallMonitor: # if no response after 10 seconds, just recv again continue response = str(response, "utf-8") + _LOGGER.debug('Received %s', response) if not response: # if the response is empty, the connection has been lost. # try to reconnect + _LOGGER.warning('Connection lost, reconnecting...') self.sock = None while self.sock is None: self.connect() From 24efda20bf2cc8b68e12a73969a0e39917f2180d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Bedn=C3=A1rik?= Date: Tue, 6 Nov 2018 12:43:47 +0100 Subject: [PATCH 052/238] Add additional property to HomeKitSwitch to show whether the Homekit outlet is in use (#17448) * Add additional property to HomeKitSwitch to show whether the Homekit outlet is in use or not * Fix issues from review - Remove unused property, simplify and rename the device_state_attributes method --- .../components/switch/homekit_controller.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py index 374e59aa77b..6333375b560 100644 --- a/homeassistant/components/switch/homekit_controller.py +++ b/homeassistant/components/switch/homekit_controller.py @@ -12,6 +12,8 @@ from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['homekit_controller'] +OUTLET_IN_USE = "outlet_in_use" + _LOGGER = logging.getLogger(__name__) @@ -29,6 +31,7 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): """Initialise the switch.""" super().__init__(*args) self._on = None + self._outlet_in_use = None def update_characteristics(self, characteristics): """Synchronise the switch state with Home Assistant.""" @@ -42,6 +45,7 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): self._on = characteristic['value'] elif ctype == "outlet-in-use": self._chars['outlet-in-use'] = characteristic['iid'] + self._outlet_in_use = characteristic['value'] @property def is_on(self): @@ -62,3 +66,11 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): 'iid': self._chars['on'], 'value': False}] self.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._outlet_in_use is not None: + return { + OUTLET_IN_USE: self._outlet_in_use, + } From 2c36b9db1f5b84c1b32b147c3485c8028222ba41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 6 Nov 2018 12:47:53 +0100 Subject: [PATCH 053/238] Add support for Google Home device tracking (#18190) * Add support for Google Home device tracking. * Use dict[key] for options. * Delete googlehome.py.save * Change stylling of name, and attr mac_address to btle_mac_address, removed unesssesarry attributes copying. --- .coveragerc | 1 + .../components/device_tracker/googlehome.py | 92 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 96 insertions(+) create mode 100644 homeassistant/components/device_tracker/googlehome.py diff --git a/.coveragerc b/.coveragerc index 17fcb505fc0..6a72d5658fb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -477,6 +477,7 @@ omit = homeassistant/components/device_tracker/freebox.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/google_maps.py + homeassistant/components/device_tracker/googlehome.py homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/hitron_coda.py homeassistant/components/device_tracker/huawei_router.py diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py new file mode 100644 index 00000000000..4ab76905478 --- /dev/null +++ b/homeassistant/components/device_tracker/googlehome.py @@ -0,0 +1,92 @@ +""" +Support for Google Home bluetooth tacker. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.googlehome/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import CONF_HOST + +REQUIREMENTS = ['ghlocalapi==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RSSI_THRESHOLD = 'rssi_threshold' + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_RSSI_THRESHOLD, default=-70): vol.Coerce(int), + })) + + +async def async_get_scanner(hass, config): + """Validate the configuration and return an Google Home scanner.""" + scanner = GoogleHomeDeviceScanner(hass, config[DOMAIN]) + await scanner.async_connect() + return scanner if scanner.success_init else None + + +class GoogleHomeDeviceScanner(DeviceScanner): + """This class queries a Google Home unit.""" + + def __init__(self, hass, config): + """Initialize the scanner.""" + from ghlocalapi.device_info import DeviceInfo + from ghlocalapi.bluetooth import BluetoothScan + + self.last_results = {} + + self.success_init = False + self._host = config[CONF_HOST] + self.rssi_threshold = config[CONF_RSSI_THRESHOLD] + + session = async_get_clientsession(hass) + self.deviceinfo = DeviceInfo(hass.loop, session, self._host) + self.scanner = BluetoothScan(hass.loop, session, self._host) + + async def async_connect(self): + """Initialize connection to Google Home.""" + await self.deviceinfo.get_device_info() + data = self.deviceinfo.device_info + self.success_init = data is not None + + async def async_scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + await self.async_update_info() + return list(self.last_results.keys()) + + async def async_get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + if device not in self.last_results: + return None + return '{}_{}'.format(self._host, + self.last_results[device]['btle_mac_address']) + + async def get_extra_attributes(self, device): + """Return the extra attributes of the device.""" + return self.last_results[device] + + async def async_update_info(self): + """Ensure the information from Google Home is up to date.""" + _LOGGER.debug('Checking Devices...') + await self.scanner.scan_for_devices() + await self.scanner.get_scan_result() + ghname = self.deviceinfo.device_info['name'] + devices = {} + for device in self.scanner.devices: + if device['rssi'] > self.rssi_threshold: + uuid = '{}_{}'.format(self._host, device['mac_address']) + devices[uuid] = {} + devices[uuid]['rssi'] = device['rssi'] + devices[uuid]['btle_mac_address'] = device['mac_address'] + devices[uuid]['ghname'] = ghname + devices[uuid]['source_type'] = 'bluetooth' + self.last_results = devices diff --git a/requirements_all.txt b/requirements_all.txt index 97490780dae..2e825c0822d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,6 +415,9 @@ geojson_client==0.3 # homeassistant.components.sensor.geo_rss_events georss_client==0.4 +# homeassistant.components.device_tracker.googlehome +ghlocalapi==0.0.1 + # homeassistant.components.sensor.gitter gitterpy==0.1.7 From 34d7758b4a04b6cdc44a763cb1da194d4168b833 Mon Sep 17 00:00:00 2001 From: Glen Takahashi Date: Tue, 6 Nov 2018 06:53:47 -0500 Subject: [PATCH 054/238] Correct expose_by_default interaction with expose_domains (#17745) Based on the documentation here: https://www.home-assistant.io/components/google_assistant/#expose_by_default it seems that expose_by_default means all devices should be exposed unless explicitly set to false, and that regardless if this is set domains in exposed_domains should be exposed. --- homeassistant/components/google_assistant/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index a6b4633e762..f29e8bbae12 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -46,7 +46,7 @@ def async_register_http(hass, cfg): entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = \ - expose_by_default and entity.domain in exposed_domains + expose_by_default or entity.domain in exposed_domains # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being From c6f3c239bbc45fd428eae2bc2b968723bfd7df8b Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 6 Nov 2018 13:08:58 +0100 Subject: [PATCH 055/238] Melissa state_attributes (#18201) * Melissa attributes * overide device_state_attributes rather than state_attributes * Selected attributes * Adding current humidity rather than a state_attribute --- homeassistant/components/climate/melissa.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py index bfb18fa0a4c..25beedfe0dd 100644 --- a/homeassistant/components/climate/melissa.py +++ b/homeassistant/components/climate/melissa.py @@ -88,6 +88,12 @@ class MelissaClimate(ClimateDevice): if self._data: return self._data[self._api.TEMP] + @property + def current_humidity(self): + """Return the current humidity value.""" + if self._data: + return self._data[self._api.HUMIDITY] + @property def target_temperature_step(self): """Return the supported step of target temperature.""" @@ -113,8 +119,9 @@ class MelissaClimate(ClimateDevice): @property def target_temperature(self): """Return the temperature we try to reach.""" - if self._cur_settings is not None: - return self._cur_settings[self._api.TEMP] + if self._cur_settings is None: + return None + return self._cur_settings[self._api.TEMP] @property def state(self): From 114bc8ec1820d9aa9351a86ce576c96bdfc1ec87 Mon Sep 17 00:00:00 2001 From: 4lloyd <4lloyd@users.noreply.github.com> Date: Tue, 6 Nov 2018 13:14:52 +0100 Subject: [PATCH 056/238] Support eco mode option on Ziggo Mediabox XL (#17990) * Added eco mode option to Ziggo Mediabox XL * Changed eco_mode_on to eco_mode * Removed eco_mode option, the player is unavailable when offline * Timeout on connection, on/off states are handled via update * Improved state detection and added available property --- .../media_player/ziggo_mediabox_xl.py | 49 +++++++++++++------ requirements_all.txt | 2 +- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/media_player/ziggo_mediabox_xl.py b/homeassistant/components/media_player/ziggo_mediabox_xl.py index 555042bee5c..57ef69c923e 100644 --- a/homeassistant/components/media_player/ziggo_mediabox_xl.py +++ b/homeassistant/components/media_player/ziggo_mediabox_xl.py @@ -14,10 +14,10 @@ from homeassistant.components.media_player import ( SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) + CONF_HOST, CONF_NAME, STATE_OFF, STATE_PAUSED, STATE_PLAYING) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['ziggo-mediabox-xl==1.0.0'] +REQUIREMENTS = ['ziggo-mediabox-xl==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -43,9 +43,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if config.get(CONF_HOST) is not None: host = config.get(CONF_HOST) name = config.get(CONF_NAME) + manual_config = True elif discovery_info is not None: host = discovery_info.get('host') name = discovery_info.get('name') + manual_config = False else: _LOGGER.error("Cannot determine device") return @@ -53,15 +55,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Only add a device once, so discovered devices do not override manual # config. hosts = [] + connection_successful = False ip_addr = socket.gethostbyname(host) if ip_addr not in known_devices: try: - mediabox = ZiggoMediaboxXL(ip_addr) + # Mediabox instance with a timeout of 3 seconds. + mediabox = ZiggoMediaboxXL(ip_addr, 3) + # Check if a connection can be established to the device. if mediabox.test_connection(): - hosts.append(ZiggoMediaboxXLDevice(mediabox, host, name)) - known_devices.add(ip_addr) + connection_successful = True else: - _LOGGER.error("Can't connect to %s", host) + if manual_config: + _LOGGER.info("Can't connect to %s", host) + else: + _LOGGER.error("Can't connect to %s", host) + # When the device is in eco mode it's not connected to the network + # so it needs to be added anyway if it's configured manually. + if manual_config or connection_successful: + hosts.append(ZiggoMediaboxXLDevice(mediabox, host, name, + connection_successful)) + known_devices.add(ip_addr) except socket.error as error: _LOGGER.error("Can't connect to %s: %s", host, error) else: @@ -72,24 +85,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ZiggoMediaboxXLDevice(MediaPlayerDevice): """Representation of a Ziggo Mediabox XL Device.""" - def __init__(self, mediabox, host, name): + def __init__(self, mediabox, host, name, available): """Initialize the device.""" - # Generate a configuration for the Samsung library self._mediabox = mediabox self._host = host self._name = name + self._available = available self._state = None def update(self): """Retrieve the state of the device.""" try: - if self._mediabox.turned_on(): - if self._state != STATE_PAUSED: - self._state = STATE_PLAYING + if self._mediabox.test_connection(): + if self._mediabox.turned_on(): + if self._state != STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + self._available = True else: - self._state = STATE_OFF + self._available = False except socket.error: _LOGGER.error("Couldn't fetch state from %s", self._host) + self._available = False def send_keys(self, keys): """Send keys to the device and handle exceptions.""" @@ -108,6 +126,11 @@ class ZiggoMediaboxXLDevice(MediaPlayerDevice): """Return the state of the device.""" return self._state + @property + def available(self): + """Return True if the device is available.""" + return self._available + @property def source_list(self): """List of available sources (channels).""" @@ -122,12 +145,10 @@ class ZiggoMediaboxXLDevice(MediaPlayerDevice): def turn_on(self): """Turn the media player on.""" self.send_keys(['POWER']) - self._state = STATE_ON def turn_off(self): """Turn off media player.""" self.send_keys(['POWER']) - self._state = STATE_OFF def media_play(self): """Send play command.""" diff --git a/requirements_all.txt b/requirements_all.txt index 2e825c0822d..a4933db7a6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1617,7 +1617,7 @@ zeroconf==0.21.3 zhong_hong_hvac==1.0.9 # homeassistant.components.media_player.ziggo_mediabox_xl -ziggo-mediabox-xl==1.0.0 +ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha zigpy-xbee==0.1.1 From 1aba4699b91c310eca597ed29c092a4ea3f60448 Mon Sep 17 00:00:00 2001 From: akloeckner Date: Tue, 6 Nov 2018 13:15:48 +0100 Subject: [PATCH 057/238] Add attributes of ARP table (#17987) * Add attributes of ARP table This adds the device attributes available in the ARP table and a few more. Implementation is inspired by the nmap scanner. * lint spaces --- .../components/device_tracker/luci.py | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index f479dea184b..30b09834b68 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/device_tracker.luci/ import json import logging import re +from collections import namedtuple import requests import voluptuous as vol @@ -43,14 +44,17 @@ def get_scanner(hass, config): return scanner if scanner.success_init else None +Device = namedtuple('Device', ['mac', 'ip', 'flags', 'device', 'host']) + + class LuciDeviceScanner(DeviceScanner): """This class queries a wireless router running OpenWrt firmware.""" def __init__(self, config): """Initialize the scanner.""" - host = config[CONF_HOST] + self.host = config[CONF_HOST] protocol = 'http' if not config[CONF_SSL] else 'https' - self.origin = '{}://{}'.format(protocol, host) + self.origin = '{}://{}'.format(protocol, self.host) self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] @@ -68,7 +72,7 @@ class LuciDeviceScanner(DeviceScanner): def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() - return self.last_results + return [device.mac for device in self.last_results] def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" @@ -88,6 +92,18 @@ class LuciDeviceScanner(DeviceScanner): return return self.mac2name.get(device.upper(), None) + def get_extra_attributes(self, device): + """Return the IP of the given device.""" + filter_att = next(( + { + 'ip': result.ip, + 'flags': result.flags, + 'device': result.device, + 'host': result.host + } for result in self.last_results + if result.mac == device), None) + return filter_att + def _update_info(self): """Ensure the information from the Luci router is up to date. @@ -114,7 +130,11 @@ class LuciDeviceScanner(DeviceScanner): # Check if the Flags for each device contain # NUD_REACHABLE and if so, add it to last_results if int(device_entry['Flags'], 16) & 0x2: - self.last_results.append(device_entry['HW address']) + self.last_results.append(Device(device_entry['HW address'], + device_entry['IP address'], + device_entry['Flags'], + device_entry['Device'], + self.host)) return True From e3a8f3a106303cf313cf4782380a72e51e31a60d Mon Sep 17 00:00:00 2001 From: Tyler Date: Tue, 6 Nov 2018 07:19:36 -0500 Subject: [PATCH 058/238] Add input_boolean reporting to Prometheus (#17966) --- homeassistant/components/prometheus.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index ee4b88d4d9b..dc868530f88 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -138,6 +138,15 @@ class PrometheusMetrics: value = state_helper.state_as_number(state) metric.labels(**self._labels(state)).set(value) + def _handle_input_boolean(self, state): + metric = self._metric( + 'input_boolean_state', + self.prometheus_client.Gauge, + 'State of the input boolean (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + def _handle_device_tracker(self, state): metric = self._metric( 'device_tracker_state', From 58c77e1f5520123e2dd36619573c2ea2b1b902e3 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Tue, 6 Nov 2018 14:16:15 +0100 Subject: [PATCH 059/238] Add Xiaomi Air Purifier 2s support (#18260) --- homeassistant/components/fan/xiaomi_miio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index a1917495732..dab7f2ab9c3 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -48,6 +48,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'zhimi.airpurifier.v3', 'zhimi.airpurifier.v5', 'zhimi.airpurifier.v6', + 'zhimi.airpurifier.mc1', 'zhimi.humidifier.v1', 'zhimi.humidifier.ca1', 'zhimi.airfresh.va2']), From 7933bd7f91030a6640c6f6f82a74e90913b969df Mon Sep 17 00:00:00 2001 From: Matthew Parlane Date: Wed, 7 Nov 2018 02:17:56 +1300 Subject: [PATCH 060/238] Allow alexa to simply turn on and off climate components. (#16989) --- homeassistant/components/alexa/smart_home.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 80e584a4a75..99d2a50bee0 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -717,6 +717,9 @@ class _ClimateCapabilities(_AlexaEntity): return [_DisplayCategory.THERMOSTAT] def interfaces(self): + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_ON_OFF: + yield _AlexaPowerController(self.entity) yield _AlexaThermostatController(self.hass, self.entity) yield _AlexaTemperatureSensor(self.hass, self.entity) From 2e517ab6bc6ae76def082d9473e092faae94dcba Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Nov 2018 14:27:52 +0100 Subject: [PATCH 061/238] Enable config flow for Luftdaten (#17700) * Move file to new location * Update requirement * Enable config flow * Add luftdaten * Add tests * Update * Add constants * Changes according to the review comments * Remove wrong entry from flows * Fix dict handling * Add callback and use OrderedDict * Remve leftover * Fix * Remove await --- CODEOWNERS | 3 +- .../components/luftdaten/__init__.py | 170 +++++++++++++++++ .../components/luftdaten/config_flow.py | 75 ++++++++ homeassistant/components/luftdaten/const.py | 10 + .../components/luftdaten/strings.json | 20 ++ homeassistant/components/sensor/luftdaten.py | 174 +++++++----------- homeassistant/config_entries.py | 1 + requirements_all.txt | 4 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 5 +- tests/components/luftdaten/__init__.py | 1 + .../components/luftdaten/test_config_flow.py | 114 ++++++++++++ tests/components/luftdaten/test_init.py | 36 ++++ 13 files changed, 508 insertions(+), 108 deletions(-) create mode 100644 homeassistant/components/luftdaten/__init__.py create mode 100644 homeassistant/components/luftdaten/config_flow.py create mode 100644 homeassistant/components/luftdaten/const.py create mode 100644 homeassistant/components/luftdaten/strings.json create mode 100644 tests/components/luftdaten/__init__.py create mode 100644 tests/components/luftdaten/test_config_flow.py create mode 100644 tests/components/luftdaten/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index bf4c342b474..0d498d89cae 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -109,7 +109,6 @@ homeassistant/components/sensor/gpsd.py @fabaff homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/jewish_calendar.py @tsvi homeassistant/components/sensor/linux_battery.py @fabaff -homeassistant/components/sensor/luftdaten.py @fabaff homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/min_max.py @fabaff homeassistant/components/sensor/moon.py @fabaff @@ -189,6 +188,8 @@ homeassistant/components/*/konnected.py @heythisisnate # L homeassistant/components/lifx.py @amelchio homeassistant/components/*/lifx.py @amelchio +homeassistant/components/luftdaten/* @fabaff +homeassistant/components/*/luftdaten.py @fabaff # M homeassistant/components/matrix.py @tinloaf diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py new file mode 100644 index 00000000000..b00fca7d3c0 --- /dev/null +++ b/homeassistant/components/luftdaten/__init__.py @@ -0,0 +1,170 @@ +""" +Support for Luftdaten stations. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/luftdaten/ +""" +import logging + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, CONF_SENSORS, + CONF_SHOW_ON_MAP, TEMP_CELSIUS) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .config_flow import configured_sensors +from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN + +REQUIREMENTS = ['luftdaten==0.3.4'] + +_LOGGER = logging.getLogger(__name__) + +DATA_LUFTDATEN = 'luftdaten' +DATA_LUFTDATEN_CLIENT = 'data_luftdaten_client' +DATA_LUFTDATEN_LISTENER = 'data_luftdaten_listener' +DEFAULT_ATTRIBUTION = "Data provided by luftdaten.info" + +SENSOR_HUMIDITY = 'humidity' +SENSOR_PM10 = 'P1' +SENSOR_PM2_5 = 'P2' +SENSOR_PRESSURE = 'pressure' +SENSOR_TEMPERATURE = 'temperature' + +TOPIC_UPDATE = '{0}_data_update'.format(DOMAIN) + +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +SENSORS = { + SENSOR_TEMPERATURE: ['Temperature', 'mdi:thermometer', TEMP_CELSIUS], + SENSOR_HUMIDITY: ['Humidity', 'mdi:water-percent', '%'], + SENSOR_PRESSURE: ['Pressure', 'mdi:arrow-down-bold', 'Pa'], + SENSOR_PM10: ['PM10', 'mdi:thought-bubble', + VOLUME_MICROGRAMS_PER_CUBIC_METER], + SENSOR_PM2_5: ['PM2.5', 'mdi:thought-bubble-outline', + VOLUME_MICROGRAMS_PER_CUBIC_METER] +} + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: + vol.Schema({ + vol.Required(CONF_SENSOR_ID): cv.positive_int, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Luftdaten component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT] = {} + hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + station_id = conf.get(CONF_SENSOR_ID) + + if station_id not in configured_sensors(hass): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data={ + CONF_SENSORS: conf[CONF_SENSORS], + CONF_SENSOR_ID: conf[CONF_SENSOR_ID], + CONF_SHOW_ON_MAP: conf[CONF_SHOW_ON_MAP], + } + ) + ) + + hass.data[DOMAIN][CONF_SCAN_INTERVAL] = conf[CONF_SCAN_INTERVAL] + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Luftdaten as config entry.""" + from luftdaten import Luftdaten + from luftdaten.exceptions import LuftdatenError + + session = async_get_clientsession(hass) + + try: + luftdaten = LuftDatenData( + Luftdaten( + config_entry.data[CONF_SENSOR_ID], hass.loop, session), + config_entry.data.get(CONF_SENSORS, {}).get( + CONF_MONITORED_CONDITIONS, list(SENSORS))) + await luftdaten.async_update() + hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT][config_entry.entry_id] = \ + luftdaten + except LuftdatenError: + raise ConfigEntryNotReady + + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + config_entry, 'sensor')) + + async def refresh_sensors(event_time): + """Refresh Luftdaten data.""" + await luftdaten.async_update() + async_dispatcher_send(hass, TOPIC_UPDATE) + + hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER][ + config_entry.entry_id] = async_track_time_interval( + hass, refresh_sensors, + hass.data[DOMAIN].get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an Luftdaten config entry.""" + remove_listener = hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER].pop( + config_entry.entry_id) + remove_listener() + + for component in ('sensor', ): + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + + hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT].pop(config_entry.entry_id) + + return True + + +class LuftDatenData: + """Define a generic Luftdaten object.""" + + def __init__(self, client, sensor_conditions): + """Initialize the Luftdata object.""" + self.client = client + self.data = {} + self.sensor_conditions = sensor_conditions + + async def async_update(self): + """Update sensor/binary sensor data.""" + from luftdaten.exceptions import LuftdatenError + + try: + await self.client.get_data() + + self.data[DATA_LUFTDATEN] = self.client.values + self.data[DATA_LUFTDATEN].update(self.client.meta) + + except LuftdatenError: + _LOGGER.error("Unable to retrieve data from luftdaten.info") diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py new file mode 100644 index 00000000000..33715c3c0c1 --- /dev/null +++ b/homeassistant/components/luftdaten/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow to configure the Luftdaten component.""" +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN + + +@callback +def configured_sensors(hass): + """Return a set of configured Luftdaten sensors.""" + return set( + '{0}'.format(entry.data[CONF_SENSOR_ID]) + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class LuftDatenFlowHandler(config_entries.ConfigFlow): + """Handle a Luftdaten config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @callback + def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = OrderedDict() + data_schema[vol.Required(CONF_SENSOR_ID)] = str + data_schema[vol.Optional(CONF_SHOW_ON_MAP, default=False)] = bool + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(data_schema), + errors=errors or {} + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from luftdaten import Luftdaten, exceptions + + if not user_input: + return self._show_form() + + sensor_id = user_input[CONF_SENSOR_ID] + + if sensor_id in configured_sensors(self.hass): + return self._show_form({CONF_SENSOR_ID: 'sensor_exists'}) + + session = aiohttp_client.async_get_clientsession(self.hass) + luftdaten = Luftdaten( + user_input[CONF_SENSOR_ID], self.hass.loop, session) + try: + await luftdaten.get_data() + valid = await luftdaten.validate_sensor() + except exceptions.LuftdatenConnectionError: + return self._show_form( + {CONF_SENSOR_ID: 'communication_error'}) + + if not valid: + return self._show_form({CONF_SENSOR_ID: 'invalid_sensor'}) + + scan_interval = user_input.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input.update({CONF_SCAN_INTERVAL: scan_interval.seconds}) + + return self.async_create_entry(title=sensor_id, data=user_input) diff --git a/homeassistant/components/luftdaten/const.py b/homeassistant/components/luftdaten/const.py new file mode 100644 index 00000000000..2f87f857545 --- /dev/null +++ b/homeassistant/components/luftdaten/const.py @@ -0,0 +1,10 @@ +"""Define constants for the Luftdaten component.""" +from datetime import timedelta + +ATTR_SENSOR_ID = 'sensor_id' + +CONF_SENSOR_ID = 'sensor_id' + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) + +DOMAIN = 'luftdaten' diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json new file mode 100644 index 00000000000..2ba15087c48 --- /dev/null +++ b/homeassistant/components/luftdaten/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "Luftdaten", + "step": { + "user": { + "title": "Define Luftdaten", + "data": { + "station_id": "Luftdaten Sensor ID", + "show_on_map": "Show on map" + + } + } + }, + "error": { + "sensor_exists": "Sensor already registered", + "invalid_sensor": "Sensor not available or invalid", + "communication_error": "Unable to communicate with the Luftdaten API" + } + } +} diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index 445ccb7214e..4752286b9b2 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -4,152 +4,120 @@ Support for Luftdaten sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.luftdaten/ """ -from datetime import timedelta import logging -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.luftdaten import ( + DATA_LUFTDATEN, DATA_LUFTDATEN_CLIENT, DEFAULT_ATTRIBUTION, DOMAIN, + SENSORS, TOPIC_UPDATE) +from homeassistant.components.luftdaten.const import ATTR_SENSOR_ID from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_MONITORED_CONDITIONS, - CONF_NAME, CONF_SHOW_ON_MAP, TEMP_CELSIUS) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -REQUIREMENTS = ['luftdaten==0.2.0'] _LOGGER = logging.getLogger(__name__) -ATTR_SENSOR_ID = 'sensor_id' - -CONF_ATTRIBUTION = "Data provided by luftdaten.info" - - -VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' - -SENSOR_TEMPERATURE = 'temperature' -SENSOR_HUMIDITY = 'humidity' -SENSOR_PM10 = 'P1' -SENSOR_PM2_5 = 'P2' -SENSOR_PRESSURE = 'pressure' - -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ['Temperature', TEMP_CELSIUS], - SENSOR_HUMIDITY: ['Humidity', '%'], - SENSOR_PRESSURE: ['Pressure', 'Pa'], - SENSOR_PM10: ['PM10', VOLUME_MICROGRAMS_PER_CUBIC_METER], - SENSOR_PM2_5: ['PM2.5', VOLUME_MICROGRAMS_PER_CUBIC_METER] -} - -DEFAULT_NAME = 'Luftdaten' - -CONF_SENSORID = 'sensorid' - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORID): cv.positive_int, - vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, -}) +DEPENDENCIES = ['luftdaten'] async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the Luftdaten sensor.""" - from luftdaten import Luftdaten + """Set up an Luftdaten sensor based on existing config.""" + pass - name = config.get(CONF_NAME) - show_on_map = config.get(CONF_SHOW_ON_MAP) - sensor_id = config.get(CONF_SENSORID) - session = async_get_clientsession(hass) - luftdaten = LuftdatenData(Luftdaten(sensor_id, hass.loop, session)) +async def async_setup_entry(hass, entry, async_add_entities): + """Set up a Luftdaten sensor based on a config entry.""" + luftdaten = hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT][entry.entry_id] - await luftdaten.async_update() + sensors = [] + for sensor_type in luftdaten.sensor_conditions: + name, icon, unit = SENSORS[sensor_type] + sensors.append( + LuftdatenSensor( + luftdaten, sensor_type, name, icon, unit, + entry.data[CONF_SHOW_ON_MAP]) + ) - if luftdaten.data is None: - _LOGGER.error("Sensor is not available: %s", sensor_id) - return - - devices = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - if luftdaten.data.values[variable] is None: - _LOGGER.warning("It might be that sensor %s is not providing " - "measurements for %s", sensor_id, variable) - devices.append( - LuftdatenSensor(luftdaten, name, variable, sensor_id, show_on_map)) - - async_add_entities(devices) + async_add_entities(sensors, True) class LuftdatenSensor(Entity): """Implementation of a Luftdaten sensor.""" - def __init__(self, luftdaten, name, sensor_type, sensor_id, show): + def __init__( + self, luftdaten, sensor_type, name, icon, unit, show): """Initialize the Luftdaten sensor.""" + self._async_unsub_dispatcher_connect = None self.luftdaten = luftdaten + self._icon = icon self._name = name - self._state = None - self._sensor_id = sensor_id + self._data = None self.sensor_type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._unit_of_measurement = unit self._show_on_map = show + self._attrs = {} @property - def name(self): - """Return the name of the sensor.""" - return '{} {}'.format(self._name, SENSOR_TYPES[self.sensor_type][0]) + def icon(self): + """Return the icon.""" + return self._icon @property def state(self): """Return the state of the device.""" - return self.luftdaten.data.values[self.sensor_type] + return self._data[self.sensor_type] @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique, friendly identifier for this entity.""" + return '{0}_{1}'.format(self._data['sensor_id'], self.sensor_type) + @property def device_state_attributes(self): """Return the state attributes.""" - onmap = ATTR_LATITUDE, ATTR_LONGITUDE - nomap = 'lat', 'long' - lat_format, lon_format = onmap if self._show_on_map else nomap + self._attrs[ATTR_SENSOR_ID] = self._data['sensor_id'] + self._attrs[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION + + on_map = ATTR_LATITUDE, ATTR_LONGITUDE + no_map = 'lat', 'long' + lat_format, lon_format = on_map if self._show_on_map else no_map try: - attr = { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_SENSOR_ID: self._sensor_id, - lat_format: self.luftdaten.data.meta['latitude'], - lon_format: self.luftdaten.data.meta['longitude'], - } - return attr + self._attrs[lon_format] = self._data['longitude'] + self._attrs[lat_format] = self._data['latitude'] + return self._attrs except KeyError: return + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + async def async_update(self): - """Get the latest data from luftdaten.info and update the state.""" - await self.luftdaten.async_update() - - -class LuftdatenData: - """Class for handling the data retrieval.""" - - def __init__(self, data): - """Initialize the data object.""" - self.data = data - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest data from luftdaten.info.""" - from luftdaten.exceptions import LuftdatenError - + """Get the latest data and update the state.""" try: - await self.data.async_get_data() - except LuftdatenError: - _LOGGER.error("Unable to retrieve data from luftdaten.info") + self._data = self.luftdaten.data[DATA_LUFTDATEN] + except KeyError: + return diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index eaef97011fc..513f225db03 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -145,6 +145,7 @@ FLOWS = [ 'ios', 'lifx', 'mailgun', + 'luftdaten', 'mqtt', 'nest', 'openuv', diff --git a/requirements_all.txt b/requirements_all.txt index a4933db7a6b..971800082ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -590,8 +590,8 @@ locationsharinglib==3.0.7 # homeassistant.components.logi_circle logi_circle==0.1.7 -# homeassistant.components.sensor.luftdaten -luftdaten==0.2.0 +# homeassistant.components.luftdaten +luftdaten==0.3.4 # homeassistant.components.light.lw12wifi lw12==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8246079fe26..1cf0de72104 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,6 +112,9 @@ libpurecoollink==0.4.2 # homeassistant.components.media_player.soundtouch libsoundtouch==0.7.2 +# homeassistant.components.luftdaten +luftdaten==0.3.4 + # homeassistant.components.sensor.mfi # homeassistant.components.switch.mfi mficlient==0.3.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 97711b5e893..e5da8b48360 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,12 +50,12 @@ TEST_REQUIREMENTS = ( 'evohomeclient', 'feedparser', 'foobot_async', - 'gTTS-token', 'geojson_client', 'georss_client', + 'gTTS-token', + 'ha-ffmpeg', 'hangups', 'HAP-python', - 'ha-ffmpeg', 'haversine', 'hbmqtt', 'hdate', @@ -65,6 +65,7 @@ TEST_REQUIREMENTS = ( 'influxdb', 'libpurecoollink', 'libsoundtouch', + 'luftdaten', 'mficlient', 'numpy', 'paho-mqtt', diff --git a/tests/components/luftdaten/__init__.py b/tests/components/luftdaten/__init__.py new file mode 100644 index 00000000000..d4249f69da2 --- /dev/null +++ b/tests/components/luftdaten/__init__.py @@ -0,0 +1 @@ +"""Define tests for the Luftdaten component.""" diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py new file mode 100644 index 00000000000..5c005507fbc --- /dev/null +++ b/tests/components/luftdaten/test_config_flow.py @@ -0,0 +1,114 @@ +"""Define tests for the Luftdaten config flow.""" +from datetime import timedelta +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.luftdaten import DOMAIN, config_flow +from homeassistant.components.luftdaten.const import CONF_SENSOR_ID +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP + +from tests.common import MockConfigEntry, mock_coro + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_SENSOR_ID: 'sensor_exists'} + + +async def test_communication_error(hass): + """Test that no sensor is added while unable to communicate with API.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + } + + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(None)): + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_SENSOR_ID: 'invalid_sensor'} + + +async def test_invalid_sensor(hass): + """Test that an invalid sensor throws an error.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + } + + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(False)),\ + patch('luftdaten.Luftdaten.validate_sensor', + return_value=mock_coro(False)): + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_SENSOR_ID: 'invalid_sensor'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + } + + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(True)), \ + patch('luftdaten.Luftdaten.validate_sensor', + return_value=mock_coro(True)): + result = await flow.async_step_import(import_config=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '12345abcde' + assert result['data'] == { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + CONF_SCAN_INTERVAL: 600, + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + CONF_SCAN_INTERVAL: timedelta(minutes=5), + } + + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(True)), \ + patch('luftdaten.Luftdaten.validate_sensor', + return_value=mock_coro(True)): + result = await flow.async_step_user(user_input=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '12345abcde' + assert result['data'] == { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + CONF_SCAN_INTERVAL: 300, + } diff --git a/tests/components/luftdaten/test_init.py b/tests/components/luftdaten/test_init.py new file mode 100644 index 00000000000..eb2c0895c59 --- /dev/null +++ b/tests/components/luftdaten/test_init.py @@ -0,0 +1,36 @@ +"""Test the Luftdaten component setup.""" +from unittest.mock import patch + +from homeassistant.components import luftdaten +from homeassistant.components.luftdaten.const import CONF_SENSOR_ID, DOMAIN +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP +from homeassistant.setup import async_setup_component + + +async def test_config_with_sensor_passed_to_config_entry(hass): + """Test that configured options for a sensor are loaded.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + CONF_SCAN_INTERVAL: 600, + } + + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(luftdaten, 'configured_sensors', return_value=[]): + assert await async_setup_component(hass, DOMAIN, conf) is True + + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_config_already_registered_not_passed_to_config_entry(hass): + """Test that an already registered sensor does not initiate an import.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + } + + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(luftdaten, 'configured_sensors', + return_value=['12345abcde']): + assert await async_setup_component(hass, DOMAIN, conf) is True + + assert len(mock_config_entries.flow.mock_calls) == 0 From 39412dc930ae94431e4de12e1d91871ecb7643b8 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 6 Nov 2018 15:18:46 +0100 Subject: [PATCH 062/238] Adding current_humidity to attributes if its not None. (#18261) * Adding current humidity if not None * Removed the logic in supported feature * More appropriate placement --- homeassistant/components/climate/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index a165521f0bd..4b73e24fb41 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -249,9 +249,11 @@ class ClimateDevice(Entity): self.hass, self.target_temperature_low, self.temperature_unit, self.precision) + if self.current_humidity is not None: + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + if supported_features & SUPPORT_TARGET_HUMIDITY: data[ATTR_HUMIDITY] = self.target_humidity - data[ATTR_CURRENT_HUMIDITY] = self.current_humidity if supported_features & SUPPORT_TARGET_HUMIDITY_LOW: data[ATTR_MIN_HUMIDITY] = self.min_humidity From 47af194d060631e36f44530baedaa67658b232f8 Mon Sep 17 00:00:00 2001 From: Ludovico de Nittis Date: Tue, 6 Nov 2018 15:30:41 +0100 Subject: [PATCH 063/238] Add iAlarm "triggered" support (#18263) --- homeassistant/components/alarm_control_panel/ialarm.py | 6 ++++-- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/ialarm.py b/homeassistant/components/alarm_control_panel/ialarm.py index 3f41ee57902..efc7436e21b 100644 --- a/homeassistant/components/alarm_control_panel/ialarm.py +++ b/homeassistant/components/alarm_control_panel/ialarm.py @@ -12,10 +12,10 @@ import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyialarm==0.2'] +REQUIREMENTS = ['pyialarm==0.3'] _LOGGER = logging.getLogger(__name__) @@ -89,6 +89,8 @@ class IAlarmPanel(alarm.AlarmControlPanel): state = STATE_ALARM_ARMED_AWAY elif status == self._client.ARMED_STAY: state = STATE_ALARM_ARMED_HOME + elif status == self._client.TRIGGERED: + state = STATE_ALARM_TRIGGERED else: state = None diff --git a/requirements_all.txt b/requirements_all.txt index 971800082ca..478a3193d16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -947,7 +947,7 @@ pyhomematic==0.1.51 pyhydroquebec==2.2.2 # homeassistant.components.alarm_control_panel.ialarm -pyialarm==0.2 +pyialarm==0.3 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 From 9329ec24867b8eed39dd88eaca78a90359a5d0af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Bedn=C3=A1rik?= Date: Tue, 6 Nov 2018 15:32:32 +0100 Subject: [PATCH 064/238] Add support for switches in homekit controller (#17916) --- homeassistant/components/homekit_controller/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index ebb4a2db9cb..45b3442ba9f 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -24,6 +24,7 @@ HOMEKIT_DIR = '.homekit' HOMEKIT_ACCESSORY_DISPATCH = { 'lightbulb': 'light', 'outlet': 'switch', + 'switch': 'switch', 'thermostat': 'climate', } From 589764900ab432f46af60fc2b0d8471c2b2323d8 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Tue, 6 Nov 2018 16:09:46 +0100 Subject: [PATCH 065/238] Move more MQTT platforms to config entries (#18180) * Move Lock MQTT platform to config entries * Move MQTT JSON Light platform to config entries * Review comments * Review comments * Revert mqtt_json changes --- homeassistant/components/lock/__init__.py | 7 ++++- homeassistant/components/lock/mqtt.py | 35 +++++++++++++++------- homeassistant/components/mqtt/discovery.py | 1 + tests/components/lock/test_mqtt.py | 7 +++-- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index e9904f0163a..22923602dc2 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -58,7 +58,7 @@ def is_locked(hass, entity_id=None): async def async_setup(hass, config): """Track states and offer events for locks.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LOCKS) await component.async_setup(config) @@ -79,6 +79,11 @@ async def async_setup(hass, config): return True +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + class LockDevice(Entity): """Representation of a lock.""" diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index ee43eb942c4..b62382e6dd1 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -16,8 +16,11 @@ from homeassistant.components.mqtt import ( CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate) from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) -from homeassistant.components import mqtt +from homeassistant.components import mqtt, lock +from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType, ConfigType _LOGGER = logging.getLogger(__name__) @@ -40,20 +43,32 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the MQTT lock.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_entities, discovery_info=None): + """Set up MQTT lock panel through configuration.yaml.""" + await _async_setup_entity(hass, config, async_add_entities) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT lock dynamically through MQTT discovery.""" + async def async_discover(discovery_payload): + """Discover and add an MQTT lock.""" + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(hass, config, async_add_entities, + discovery_payload[ATTR_DISCOVERY_HASH]) + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(lock.DOMAIN, 'mqtt'), + async_discover) + + +async def _async_setup_entity(hass, config, async_add_entities, + discovery_hash=None): + """Set up the MQTT Lock platform.""" value_template = config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass - discovery_hash = None - if discovery_info is not None and ATTR_DISCOVERY_HASH in discovery_info: - discovery_hash = discovery_info[ATTR_DISCOVERY_HASH] - async_add_entities([MqttLock( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index b8c8627c038..91f62cd0848 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -45,6 +45,7 @@ CONFIG_ENTRY_PLATFORMS = { 'camera': ['mqtt'], 'cover': ['mqtt'], 'light': ['mqtt'], + 'lock': ['mqtt'], 'sensor': ['mqtt'], 'switch': ['mqtt'], 'climate': ['mqtt'], diff --git a/tests/components/lock/test_mqtt.py b/tests/components/lock/test_mqtt.py index 347005c75ac..58f328c5b9d 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/lock/test_mqtt.py @@ -2,10 +2,10 @@ from homeassistant.setup import async_setup_component from homeassistant.const import ( STATE_LOCKED, STATE_UNLOCKED, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) -import homeassistant.components.lock as lock +from homeassistant.components import lock, mqtt from homeassistant.components.mqtt.discovery import async_start -from tests.common import async_fire_mqtt_message +from tests.common import async_fire_mqtt_message, MockConfigEntry async def test_controlling_state_via_topic(hass, mqtt_mock): @@ -136,7 +136,8 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_discovery_removal_lock(hass, mqtt_mock, caplog): """Test removal of discovered lock.""" - await async_start(hass, 'homeassistant', {'mqtt': {}}) + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) data = ( '{ "name": "Beer",' ' "command_topic": "test_topic" }' From eb385515c802d024bba50b1870aac2529845774e Mon Sep 17 00:00:00 2001 From: Georgi Kirichkov Date: Tue, 6 Nov 2018 17:10:17 +0200 Subject: [PATCH 066/238] Switch OwnTracks HTTP to use webhook component (#17034) * Update OwnTracks_HTTP to use the webhook component * Update owntracks_http.py * Update owntracks_http.py --- .../device_tracker/owntracks_http.py | 87 ++++++++----- .../device_tracker/test_owntracks_http.py | 122 +++++++++++++----- 2 files changed, 150 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py index b9a813738ad..b9f379e7534 100644 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -4,52 +4,79 @@ Device tracker platform that adds support for OwnTracks over HTTP. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks_http/ """ +import json +import logging import re -from aiohttp.web_exceptions import HTTPInternalServerError - -from homeassistant.components.http import HomeAssistantView +from aiohttp.web import Response +import voluptuous as vol # pylint: disable=unused-import -from .owntracks import ( # NOQA - REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message) +from homeassistant.components.device_tracker.owntracks import ( # NOQA + PLATFORM_SCHEMA, REQUIREMENTS, async_handle_message, context_from_config) +from homeassistant.const import CONF_WEBHOOK_ID +import homeassistant.helpers.config_validation as cv +DEPENDENCIES = ['webhook'] -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) + +EVENT_RECEIVED = 'owntracks_http_webhook_received' +EVENT_RESPONSE = 'owntracks_http_webhook_response_' + +DOMAIN = 'device_tracker.owntracks_http' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_WEBHOOK_ID): cv.string +}) async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an OwnTracks tracker.""" + """Set up OwnTracks HTTP component.""" context = context_from_config(async_see, config) - hass.http.register_view(OwnTracksView(context)) + subscription = context.mqtt_topic + topic = re.sub('/#$', '', subscription) - return True + async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + headers = request.headers + data = dict() + if 'X-Limit-U' in headers: + data['user'] = headers['X-Limit-U'] + elif 'u' in request.query: + data['user'] = request.query['u'] + else: + return Response( + body=json.dumps({'error': 'You need to supply username.'}), + content_type="application/json" + ) -class OwnTracksView(HomeAssistantView): - """View to handle OwnTracks HTTP requests.""" - - url = '/api/owntracks/{user}/{device}' - name = 'api:owntracks' - - def __init__(self, context): - """Initialize OwnTracks URL endpoints.""" - self.context = context - - async def post(self, request, user, device): - """Handle an OwnTracks message.""" - hass = request.app['hass'] - - subscription = self.context.mqtt_topic - topic = re.sub('/#$', '', subscription) + if 'X-Limit-D' in headers: + data['device'] = headers['X-Limit-D'] + elif 'd' in request.query: + data['device'] = request.query['d'] + else: + return Response( + body=json.dumps({'error': 'You need to supply device name.'}), + content_type="application/json" + ) message = await request.json() - message['topic'] = '{}/{}/{}'.format(topic, user, device) + + message['topic'] = '{}/{}/{}'.format(topic, data['user'], + data['device']) try: - await async_handle_message(hass, self.context, message) - return self.json([]) - + await async_handle_message(hass, context, message) + return Response(body=json.dumps([]), status=200, + content_type="application/json") except ValueError: - raise HTTPInternalServerError + _LOGGER.error("Received invalid JSON") + return None + + hass.components.webhook.async_register( + 'owntracks', 'OwnTracks', config['webhook_id'], handle_webhook) + + return True diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/device_tracker/test_owntracks_http.py index d7b48cafe46..a49f30c6839 100644 --- a/tests/components/device_tracker/test_owntracks_http.py +++ b/tests/components/device_tracker/test_owntracks_http.py @@ -2,11 +2,47 @@ import asyncio from unittest.mock import patch +import os import pytest +from homeassistant.components import device_tracker from homeassistant.setup import async_setup_component -from tests.common import mock_coro, mock_component +from tests.common import mock_component, mock_coro + +MINIMAL_LOCATION_MESSAGE = { + '_type': 'location', + 'lon': 45, + 'lat': 90, + 'p': 101.3977584838867, + 'tid': 'test', + 'tst': 1, +} + +LOCATION_MESSAGE = { + '_type': 'location', + 'acc': 60, + 'alt': 27, + 'batt': 92, + 'cog': 248, + 'lon': 45, + 'lat': 90, + 'p': 101.3977584838867, + 'tid': 'test', + 't': 'u', + 'tst': 1, + 'vac': 4, + 'vel': 0 +} + + +@pytest.fixture(autouse=True) +def owntracks_http_cleanup(hass): + """Remove known_devices.yaml.""" + try: + os.remove(hass.config.path(device_tracker.YAML_DEVICES)) + except OSError: + pass @pytest.fixture @@ -19,42 +55,70 @@ def mock_client(hass, aiohttp_client): hass.loop.run_until_complete( async_setup_component(hass, 'device_tracker', { 'device_tracker': { - 'platform': 'owntracks_http' + 'platform': 'owntracks_http', + 'webhook_id': 'owntracks_test' } })) return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) -@pytest.fixture -def mock_handle_message(): - """Mock async_handle_message.""" - with patch('homeassistant.components.device_tracker.' - 'owntracks_http.async_handle_message') as mock: - mock.return_value = mock_coro(None) - yield mock - - @asyncio.coroutine -def test_forward_message_correctly(mock_client, mock_handle_message): - """Test that we forward messages correctly to OwnTracks handle message.""" - resp = yield from mock_client.post('/api/owntracks/user/device', json={ - '_type': 'test' - }) +def test_handle_valid_message(mock_client): + """Test that we forward messages correctly to OwnTracks.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test?' + 'u=test&d=test', + json=LOCATION_MESSAGE) + assert resp.status == 200 - assert len(mock_handle_message.mock_calls) == 1 - data = mock_handle_message.mock_calls[0][1][2] - assert data == { - '_type': 'test', - 'topic': 'owntracks/user/device' - } + json = yield from resp.json() + assert json == [] @asyncio.coroutine -def test_handle_value_error(mock_client, mock_handle_message): - """Test that we handle errors from handle message correctly.""" - mock_handle_message.side_effect = ValueError - resp = yield from mock_client.post('/api/owntracks/user/device', json={ - '_type': 'test' - }) - assert resp.status == 500 +def test_handle_valid_minimal_message(mock_client): + """Test that we forward messages correctly to OwnTracks.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test?' + 'u=test&d=test', + json=MINIMAL_LOCATION_MESSAGE) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == [] + + +@asyncio.coroutine +def test_handle_value_error(mock_client): + """Test we don't disclose that this is a valid webhook.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test' + '?u=test&d=test', json='') + + assert resp.status == 200 + + json = yield from resp.text() + assert json == "" + + +@asyncio.coroutine +def test_returns_error_missing_username(mock_client): + """Test that an error is returned when username is missing.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test?d=test', + json=LOCATION_MESSAGE) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == {'error': 'You need to supply username.'} + + +@asyncio.coroutine +def test_returns_error_missing_device(mock_client): + """Test that an error is returned when device name is missing.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test?u=test', + json=LOCATION_MESSAGE) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == {'error': 'You need to supply device name.'} From 52074ee9bb1bfd32f5b35cee0b8ece90929da7f2 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Tue, 6 Nov 2018 07:10:39 -0800 Subject: [PATCH 067/238] Update Neato states, actions and alerts based on Neato docs (#17353) * Update neato sstates actions and alerts based on neato docs * Remove unused STATES --- homeassistant/components/neato.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 38af84e3176..6c5fac074ba 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -31,29 +31,22 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) -STATES = { - 1: 'Idle', - 2: 'Busy', - 3: 'Pause', - 4: 'Error' -} - MODE = { 1: 'Eco', 2: 'Turbo' } ACTION = { - 0: 'No action', - 1: 'House cleaning', - 2: 'Spot cleaning', - 3: 'Manual cleaning', + 0: 'Invalid', + 1: 'House Cleaning', + 2: 'Spot Cleaning', + 3: 'Manual Cleaning', 4: 'Docking', - 5: 'User menu active', - 6: 'Cleaning cancelled', - 7: 'Updating...', - 8: 'Copying logs...', - 9: 'Calculating position...', + 5: 'User Menu Active', + 6: 'Suspended Cleaning', + 7: 'Updating', + 8: 'Copying logs', + 9: 'Recovering Location', 10: 'IEC test', 11: 'Map cleaning', 12: 'Exploring map (creating a persistent map)', @@ -98,7 +91,8 @@ ALERTS = { 'dustbin_full': 'Please empty dust bin', 'maint_brush_change': 'Change the brush', 'maint_filter_change': 'Change the filter', - 'clean_completed_to_start': 'Cleaning completed' + 'clean_completed_to_start': 'Cleaning completed', + 'nav_floorplan_not_created': 'No floorplan found' } From 42fea4fb9771c2df59393b550bd577a524510ff1 Mon Sep 17 00:00:00 2001 From: Matt Schmitt Date: Tue, 6 Nov 2018 10:11:10 -0500 Subject: [PATCH 068/238] Add services to set/update and cancel Nest ETA (#17836) * Add service to cancel ETA * Update test requirements * Change service name and update logging * Reformat logging to verify structures --- homeassistant/components/nest/__init__.py | 129 +++++++++++++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 94 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index bb0e6247de3..5f584d20c55 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -12,10 +12,12 @@ import threading import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.climate import ( + ATTR_AWAY_MODE, SERVICE_SET_AWAY_MODE) from homeassistant.const import ( - CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, - CONF_MONITORED_CONDITIONS, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + CONF_BINARY_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, + CONF_SENSORS, CONF_STRUCTURE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send, \ @@ -25,11 +27,13 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from . import local_auth -REQUIREMENTS = ['python-nest==4.0.3'] +REQUIREMENTS = ['python-nest==4.0.4'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) +SERVICE_CANCEL_ETA = 'cancel_eta' +SERVICE_SET_ETA = 'set_eta' DATA_NEST = 'nest' DATA_NEST_CONFIG = 'nest_config' @@ -40,27 +44,18 @@ NEST_CONFIG_FILE = 'nest.conf' CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' -ATTR_HOME_MODE = 'home_mode' -ATTR_STRUCTURE = 'structure' -ATTR_TRIP_ID = 'trip_id' ATTR_ETA = 'eta' ATTR_ETA_WINDOW = 'eta_window' +ATTR_STRUCTURE = 'structure' +ATTR_TRIP_ID = 'trip_id' -HOME_MODE_AWAY = 'away' -HOME_MODE_HOME = 'home' +AWAY_MODE_AWAY = 'away' +AWAY_MODE_HOME = 'home' SENSOR_SCHEMA = vol.Schema({ vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list) }) -AWAY_SCHEMA = vol.Schema({ - vol.Required(ATTR_HOME_MODE): vol.In([HOME_MODE_AWAY, HOME_MODE_HOME]), - vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_TRIP_ID): cv.string, - vol.Optional(ATTR_ETA): cv.time_period, - vol.Optional(ATTR_ETA_WINDOW): cv.time_period -}) - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_CLIENT_ID): cv.string, @@ -71,6 +66,23 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), + vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]) +}) + +SET_ETA_SCHEMA = vol.Schema({ + vol.Required(ATTR_ETA): cv.time_period, + vol.Optional(ATTR_TRIP_ID): cv.string, + vol.Optional(ATTR_ETA_WINDOW): cv.time_period, + vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]) +}) + +CANCEL_ETA_SCHEMA = vol.Schema({ + vol.Required(ATTR_TRIP_ID): cv.string, + vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]) +}) + def nest_update_event_broker(hass, nest): """ @@ -134,40 +146,83 @@ async def async_setup_entry(hass, entry): hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, component)) - def set_mode(service): - """ - Set the home/away mode for a Nest structure. + def validate_structures(target_structures): + all_structures = [structure.name for structure in nest.structures] + for target in target_structures: + if target not in all_structures: + _LOGGER.info("Invalid structure: %s", target) - You can set optional eta information when set mode to away. - """ + def set_away_mode(service): + """Set the away mode for a Nest structure.""" if ATTR_STRUCTURE in service.data: - structures = service.data[ATTR_STRUCTURE] + target_structures = service.data[ATTR_STRUCTURE] + validate_structures(target_structures) else: - structures = hass.data[DATA_NEST].local_structure + target_structures = hass.data[DATA_NEST].local_structure for structure in nest.structures: - if structure.name in structures: - _LOGGER.info("Setting mode for %s", structure.name) - structure.away = service.data[ATTR_HOME_MODE] + if structure.name in target_structures: + _LOGGER.info("Setting away mode for: %s to: %s", + structure.name, service.data[ATTR_AWAY_MODE]) + structure.away = service.data[ATTR_AWAY_MODE] + + def set_eta(service): + """Set away mode to away and include ETA for a Nest structure.""" + if ATTR_STRUCTURE in service.data: + target_structures = service.data[ATTR_STRUCTURE] + validate_structures(target_structures) + else: + target_structures = hass.data[DATA_NEST].local_structure + + for structure in nest.structures: + if structure.name in target_structures: + if structure.thermostats: + _LOGGER.info("Setting away mode for: %s to: %s", + structure.name, AWAY_MODE_AWAY) + structure.away = AWAY_MODE_AWAY - if service.data[ATTR_HOME_MODE] == HOME_MODE_AWAY \ - and ATTR_ETA in service.data: now = datetime.utcnow() + trip_id = service.data.get( + ATTR_TRIP_ID, "trip_{}".format(int(now.timestamp()))) eta_begin = now + service.data[ATTR_ETA] eta_window = service.data.get(ATTR_ETA_WINDOW, timedelta(minutes=1)) eta_end = eta_begin + eta_window - trip_id = service.data.get( - ATTR_TRIP_ID, "trip_{}".format(int(now.timestamp()))) - _LOGGER.info("Setting eta for %s, eta window starts at " - "%s ends at %s", trip_id, eta_begin, eta_end) + _LOGGER.info("Setting ETA for trip: %s, " + "ETA window starts at: %s and ends at: %s", + trip_id, eta_begin, eta_end) structure.set_eta(trip_id, eta_begin, eta_end) - else: - _LOGGER.error("Invalid structure %s", - service.data[ATTR_STRUCTURE]) + else: + _LOGGER.info("No thermostats found in structure: %s, " + "unable to set ETA", structure.name) + + def cancel_eta(service): + """Cancel ETA for a Nest structure.""" + if ATTR_STRUCTURE in service.data: + target_structures = service.data[ATTR_STRUCTURE] + validate_structures(target_structures) + else: + target_structures = hass.data[DATA_NEST].local_structure + + for structure in nest.structures: + if structure.name in target_structures: + if structure.thermostats: + trip_id = service.data[ATTR_TRIP_ID] + _LOGGER.info("Cancelling ETA for trip: %s", trip_id) + structure.cancel_eta(trip_id) + else: + _LOGGER.info("No thermostats found in structure: %s, " + "unable to cancel ETA", structure.name) hass.services.async_register( - DOMAIN, 'set_mode', set_mode, schema=AWAY_SCHEMA) + DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, + schema=SET_AWAY_MODE_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_SET_ETA, set_eta, schema=SET_ETA_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_CANCEL_ETA, cancel_eta, schema=CANCEL_ETA_SCHEMA) @callback def start_up(event): diff --git a/requirements_all.txt b/requirements_all.txt index 478a3193d16..10c8a2e365f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1189,7 +1189,7 @@ python-mpd2==1.0.0 python-mystrom==0.4.4 # homeassistant.components.nest -python-nest==4.0.3 +python-nest==4.0.4 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1cf0de72104..42c80cea71b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -196,7 +196,7 @@ pyspcwebgw==0.4.0 python-forecastio==1.4.0 # homeassistant.components.nest -python-nest==4.0.3 +python-nest==4.0.4 # homeassistant.components.sensor.whois pythonwhois==2.4.3 From bde02afe4fa93e56394a271d0aa21e88c5c9c41e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 6 Nov 2018 16:33:31 +0100 Subject: [PATCH 069/238] Normalize MAC addresses (#16916) * Normalize MAC addresses * Handle all mac formats --- homeassistant/helpers/device_registry.py | 25 ++++++ tests/helpers/test_device_registry.py | 99 ++++++++++++++++++++---- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 69a3f234c22..2314ea77516 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -38,6 +38,25 @@ class DeviceEntry: id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) +def format_mac(mac): + """Format the mac address string for entry into dev reg.""" + to_test = mac + + if len(to_test) == 17 and to_test.count(':') == 5: + return to_test.lower() + elif len(to_test) == 17 and to_test.count('-') == 5: + to_test = to_test.replace('-', '') + elif len(to_test) == 14 and to_test.count('.') == 2: + to_test = to_test.replace('.', '') + + if len(to_test) == 12: + # no : included + return ':'.join(to_test.lower()[i:i + 2] for i in range(0, 12, 2)) + + # Not sure how formatted, return original + return mac + + class DeviceRegistry: """Class to hold a registry of devices.""" @@ -71,6 +90,12 @@ class DeviceRegistry: if connections is None: connections = set() + connections = { + (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC + else (key, value) + for key, value in connections + } + device = self.async_get_device(identifiers, connections) if device is None: diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 2f203ceb963..59bcab92b1e 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -17,7 +17,9 @@ async def test_get_or_create_returns_same_entry(registry): """Make sure we do not duplicate entries.""" entry = registry.async_get_or_create( config_entry_id='1234', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, sw_version='sw-version', name='name', @@ -25,12 +27,16 @@ async def test_get_or_create_returns_same_entry(registry): model='model') entry2 = registry.async_get_or_create( config_entry_id='1234', - connections={('ethernet', '11:22:33:44:55:66:77:88')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '11:22:33:66:77:88') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( config_entry_id='1234', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')} + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + } ) assert len(registry.devices) == 1 @@ -48,7 +54,9 @@ async def test_requirement_for_identifier_or_connection(registry): """Make sure we do require some descriptor of device.""" entry = registry.async_get_or_create( config_entry_id='1234', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers=set(), manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( @@ -72,17 +80,23 @@ async def test_multiple_config_entries(registry): """Make sure we do not get duplicate entries.""" entry = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( config_entry_id='456', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') @@ -112,7 +126,7 @@ async def test_loading_from_storage(hass, hass_storage): 'identifiers': [ [ 'serial', - '12:34:56:78:90:AB:CD:EF' + '12:34:56:AB:CD:EF' ] ], 'manufacturer': 'manufacturer', @@ -129,7 +143,7 @@ async def test_loading_from_storage(hass, hass_storage): entry = registry.async_get_or_create( config_entry_id='1234', connections={('Zigbee', '01.23.45.67.89')}, - identifiers={('serial', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('serial', '12:34:56:AB:CD:EF')}, manufacturer='manufacturer', model='model') assert entry.id == 'abcdefghijklm' assert isinstance(entry.config_entries, set) @@ -139,17 +153,23 @@ async def test_removing_config_entries(registry): """Make sure we do not get duplicate entries.""" entry = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( config_entry_id='456', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '34:56:78:90:AB:CD:EF:12')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '34:56:78:CD:EF:12') + }, identifiers={('bridgeid', '4567')}, manufacturer='manufacturer', model='model') @@ -170,7 +190,9 @@ async def test_specifying_hub_device_create(registry): """Test specifying a hub and updating.""" hub = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('hue', '0123')}, manufacturer='manufacturer', model='hub') @@ -197,7 +219,9 @@ async def test_specifying_hub_device_update(registry): hub = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('hue', '0123')}, manufacturer='manufacturer', model='hub') @@ -215,7 +239,9 @@ async def test_loading_saving_data(hass, registry): """Test that we load/save data correctly.""" orig_hub = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('hue', '0123')}, manufacturer='manufacturer', model='hub') @@ -259,3 +285,46 @@ async def test_no_unnecessary_changes(registry): assert entry.id == entry2.id assert len(mock_save.mock_calls) == 0 + + +async def test_format_mac(registry): + """Make sure we normalize mac addresses.""" + entry = registry.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, + ) + for mac in [ + '123456ABCDEF', + '123456abcdef', + '12:34:56:ab:cd:ef', + '1234.56ab.cdef', + ]: + test_entry = registry.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, mac) + }, + ) + assert test_entry.id == entry.id, mac + assert test_entry.connections == { + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:ab:cd:ef') + } + + # This should not raise + for invalid in [ + 'invalid_mac', + '123456ABCDEFG', # 1 extra char + '12:34:56:ab:cdef', # not enough : + '12:34:56:ab:cd:e:f', # too many : + '1234.56abcdef', # not enough . + '123.456.abc.def', # too many . + ]: + invalid_mac_entry = registry.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, invalid) + }, + ) + assert list(invalid_mac_entry.connections)[0][1] == invalid From f4d3d5904e5ced29c052281191e27e5a1f91d433 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 6 Nov 2018 16:36:12 +0100 Subject: [PATCH 070/238] HmIP thermostat fix with operations (#18068) * HmIP thermostat fix with operations * Update homematic.py --- homeassistant/components/climate/homematic.py | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index 5b741a87b45..5233501ec30 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -7,17 +7,16 @@ https://home-assistant.io/components/climate.homematic/ import logging from homeassistant.components.climate import ( - STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - ClimateDevice) + STATE_AUTO, STATE_MANUAL, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, ClimateDevice) from homeassistant.components.homematic import ( ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT, HMDevice) -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS DEPENDENCIES = ['homematic'] _LOGGER = logging.getLogger(__name__) -STATE_MANUAL = 'manual' STATE_BOOST = 'boost' STATE_COMFORT = 'comfort' STATE_LOWERING = 'lowering' @@ -41,7 +40,7 @@ HM_HUMI_MAP = [ ] HM_CONTROL_MODE = 'CONTROL_MODE' -HM_IP_CONTROL_MODE = 'SET_POINT_MODE' +HMIP_CONTROL_MODE = 'SET_POINT_MODE' SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE @@ -78,21 +77,17 @@ class HMThermostat(HMDevice, ClimateDevice): if HM_CONTROL_MODE not in self._data: return None - set_point_mode = self._data.get('SET_POINT_MODE', -1) - control_mode = self._data.get('CONTROL_MODE', -1) - boost_mode = self._data.get('BOOST_MODE', False) - # boost mode is active - if boost_mode: + if self._data.get('BOOST_MODE', False): return STATE_BOOST - # HM ip etrv 2 uses the set_point_mode to say if its + # HmIP uses the set_point_mode to say if its # auto or manual - if not set_point_mode == -1: - code = set_point_mode + if HMIP_CONTROL_MODE in self._data: + code = self._data[HMIP_CONTROL_MODE] # Other devices use the control_mode else: - code = control_mode + code = self._data['CONTROL_MODE'] # get the name of the mode name = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][code] @@ -101,12 +96,15 @@ class HMThermostat(HMDevice, ClimateDevice): @property def operation_list(self): """Return the list of available operation modes.""" - op_list = [] + # HMIP use set_point_mode for operation + if HMIP_CONTROL_MODE in self._data: + return [STATE_MANUAL, STATE_AUTO, STATE_BOOST] + # HM + op_list = [] for mode in self._hmdevice.ACTIONNODE: if mode in HM_STATE_MAP: op_list.append(HM_STATE_MAP.get(mode)) - return op_list @property @@ -157,11 +155,11 @@ class HMThermostat(HMDevice, ClimateDevice): def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" self._state = next(iter(self._hmdevice.WRITENODE.keys())) - self._data[self._state] = STATE_UNKNOWN + self._data[self._state] = None if HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE or \ - HM_IP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: - self._data[HM_CONTROL_MODE] = STATE_UNKNOWN + HMIP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: + self._data[HM_CONTROL_MODE] = None for node in self._hmdevice.SENSORNODE.keys(): - self._data[node] = STATE_UNKNOWN + self._data[node] = None From 43ae57cc59d4cc0b1faacbbb1022a034b3bdd322 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 6 Nov 2018 19:27:52 +0100 Subject: [PATCH 071/238] Lint --- homeassistant/helpers/device_registry.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 2314ea77516..78d15e57f38 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -44,7 +44,8 @@ def format_mac(mac): if len(to_test) == 17 and to_test.count(':') == 5: return to_test.lower() - elif len(to_test) == 17 and to_test.count('-') == 5: + + if len(to_test) == 17 and to_test.count('-') == 5: to_test = to_test.replace('-', '') elif len(to_test) == 14 and to_test.count('.') == 2: to_test = to_test.replace('.', '') From 917ebed4c90ab7d158e5466ff9e636cf140ed7f5 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Tue, 6 Nov 2018 13:47:19 -0500 Subject: [PATCH 072/238] Update PR checklist with commented out code check (#18272) Along with hopefully helping resolve these actual problems, a nudge for people to look at their own code might help catch other quick fixes before anyone else has to review a PR. --- .github/PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1e37cf86fc3..3bc284627fc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,6 +13,7 @@ ## Checklist: - [ ] The code change is tested and works locally. - [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** + - [ ] There is no commented out code in this PR. If user exposed functionality or configuration variables are added/changed: - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) From c41ca37a0409c184fc0aa2fe2bddf527b24be1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 6 Nov 2018 19:49:38 +0100 Subject: [PATCH 073/238] Add Norwegian Public Transportation sensor (Ruter). (#18237) * Add Norwegian Public Transportation sensor (Ruter). * Corrected typo. * change stopid to stop_id, actually use attributes, changed logging, corrected link, removed unused variable. * Change to RuterSensor for the class, and move logic to me more readable. * Use correct sensor class. * Add return if blank list, remove else --- .coveragerc | 1 + homeassistant/components/sensor/ruter.py | 92 ++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 96 insertions(+) create mode 100644 homeassistant/components/sensor/ruter.py diff --git a/.coveragerc b/.coveragerc index 6a72d5658fb..1538435f564 100644 --- a/.coveragerc +++ b/.coveragerc @@ -768,6 +768,7 @@ omit = homeassistant/components/sensor/rainbird.py homeassistant/components/sensor/ripple.py homeassistant/components/sensor/rtorrent.py + homeassistant/components/sensor/ruter.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sensehat.py homeassistant/components/sensor/serial_pm.py diff --git a/homeassistant/components/sensor/ruter.py b/homeassistant/components/sensor/ruter.py new file mode 100644 index 00000000000..06514e35a54 --- /dev/null +++ b/homeassistant/components/sensor/ruter.py @@ -0,0 +1,92 @@ +""" +A sensor platform that give you information about next departures from Ruter. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/sensor.ruter/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['pyruter==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_STOP_ID = 'stop_id' +CONF_DESTINATION = 'destination' +CONF_OFFSET = 'offset' + +DEFAULT_NAME = 'Ruter' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STOP_ID): cv.positive_int, + vol.Optional(CONF_DESTINATION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OFFSET, default=1): cv.positive_int, + }) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Create the sensor.""" + from pyruter.api import Departures + + stop_id = config[CONF_STOP_ID] + destination = config.get(CONF_DESTINATION) + name = config[CONF_NAME] + offset = config[CONF_OFFSET] + + ruter = Departures(hass.loop, stop_id, destination) + sensor = [RuterSensor(ruter, name, offset)] + async_add_entities(sensor, True) + + +class RuterSensor(Entity): + """Representation of a Ruter sensor.""" + + def __init__(self, ruter, name, offset): + """Initialize the sensor.""" + self.ruter = ruter + self._attributes = {} + self._name = name + self._offset = offset + self._state = None + + async def async_update(self): + """Get the latest data from the Ruter API.""" + await self.ruter.get_departures() + if self.ruter.departures is None: + _LOGGER.error("No data recieved from Ruter.") + return + try: + data = self.ruter.departures[self._offset] + self._state = data['time'] + self._attributes['line'] = data['line'] + self._attributes['destination'] = data['destination'] + except (KeyError, IndexError) as error: + _LOGGER.debug("Error getting data from Ruter, %s", error) + + @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): + """Return the icon of the sensor.""" + return 'mdi:bus' + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + return self._attributes diff --git a/requirements_all.txt b/requirements_all.txt index 10c8a2e365f..859be19f8d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1080,6 +1080,9 @@ pyrainbird==0.1.6 # homeassistant.components.switch.recswitch pyrecswitch==1.0.2 +# homeassistant.components.sensor.ruter +pyruter==1.0.2 + # homeassistant.components.sabnzbd pysabnzbd==1.1.0 From bdba3852d03e948495d875ee6ed687fe93166bc9 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Tue, 6 Nov 2018 11:12:03 -0800 Subject: [PATCH 074/238] Split out geofency with a component and platform (#17933) * Split out geofency with a component and platform * Make geofency component/device_tracker more async * Move geofency tests to new package * Remove coroutine in geofency callback * Lint * Fix coroutine in geofency callback * Fix incorrect patch --- .../components/device_tracker/geofency.py | 131 ++-------------- homeassistant/components/geofency/__init__.py | 146 ++++++++++++++++++ .../device_tracker/test_owntracks.py | 14 +- tests/components/geofency/__init__.py | 1 + .../test_init.py} | 56 +++---- 5 files changed, 197 insertions(+), 151 deletions(-) create mode 100644 homeassistant/components/geofency/__init__.py create mode 100644 tests/components/geofency/__init__.py rename tests/components/{device_tracker/test_geofency.py => geofency/test_init.py} (82%) diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py index 3687571c118..cec494f322c 100644 --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/device_tracker/geofency.py @@ -4,129 +4,26 @@ Support for the Geofency platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.geofency/ """ -from functools import partial import logging -import voluptuous as vol - -from homeassistant.components.device_tracker import PLATFORM_SCHEMA -from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify +from homeassistant.components.geofency import TRACKER_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['http'] - -ATTR_CURRENT_LATITUDE = 'currentLatitude' -ATTR_CURRENT_LONGITUDE = 'currentLongitude' - -BEACON_DEV_PREFIX = 'beacon' -CONF_MOBILE_BEACONS = 'mobile_beacons' - -LOCATION_ENTRY = '1' -LOCATION_EXIT = '0' - -URL = '/api/geofency' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MOBILE_BEACONS): vol.All( - cv.ensure_list, [cv.string]), -}) +DEPENDENCIES = ['geofency'] -def setup_scanner(hass, config, see, discovery_info=None): - """Set up an endpoint for the Geofency application.""" - mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] - - hass.http.register_view(GeofencyView(see, mobile_beacons)) - - return True - - -class GeofencyView(HomeAssistantView): - """View to handle Geofency requests.""" - - url = URL - name = 'api:geofency' - - def __init__(self, see, mobile_beacons): - """Initialize Geofency url endpoints.""" - self.see = see - self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] - - async def post(self, request): - """Handle Geofency requests.""" - data = await request.post() - hass = request.app['hass'] - - data = self._validate_data(data) - if not data: - return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) - - if self._is_mobile_beacon(data): - return await self._set_location(hass, data, None) - if data['entry'] == LOCATION_ENTRY: - location_name = data['name'] - else: - location_name = STATE_NOT_HOME - if ATTR_CURRENT_LATITUDE in data: - data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] - data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] - - return await self._set_location(hass, data, location_name) - - @staticmethod - def _validate_data(data): - """Validate POST payload.""" - data = data.copy() - - required_attributes = ['address', 'device', 'entry', - 'latitude', 'longitude', 'name'] - - valid = True - for attribute in required_attributes: - if attribute not in data: - valid = False - _LOGGER.error("'%s' not specified in message", attribute) - - if not valid: - return False - - data['address'] = data['address'].replace('\n', ' ') - data['device'] = slugify(data['device']) - data['name'] = slugify(data['name']) - - gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] - - for attribute in gps_attributes: - if attribute in data: - data[attribute] = float(data[attribute]) - - return data - - def _is_mobile_beacon(self, data): - """Check if we have a mobile beacon.""" - return 'beaconUUID' in data and data['name'] in self.mobile_beacons - - @staticmethod - def _device_name(data): - """Return name of device tracker.""" - if 'beaconUUID' in data: - return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) - return data['device'] - - async def _set_location(self, hass, data, location_name): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up the Geofency device tracker.""" + async def _set_location(device, gps, location_name, attributes): """Fire HA event to set location.""" - device = self._device_name(data) + await async_see( + dev_id=device, + gps=gps, + location_name=location_name, + attributes=attributes + ) - await hass.async_add_job( - partial(self.see, dev_id=device, - gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), - location_name=location_name, - attributes=data)) - - return "Setting location for {}".format(device) + async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) + return True diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py new file mode 100644 index 00000000000..92f8f475e65 --- /dev/null +++ b/homeassistant/components/geofency/__init__.py @@ -0,0 +1,146 @@ +""" +Support for Geofency. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/geofency/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, \ + ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'geofency' +DEPENDENCIES = ['http'] + +CONF_MOBILE_BEACONS = 'mobile_beacons' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Optional(CONF_MOBILE_BEACONS, default=[]): vol.All( + cv.ensure_list, + [cv.string] + ), + }), +}, extra=vol.ALLOW_EXTRA) + +ATTR_CURRENT_LATITUDE = 'currentLatitude' +ATTR_CURRENT_LONGITUDE = 'currentLongitude' + +BEACON_DEV_PREFIX = 'beacon' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) + + +async def async_setup(hass, hass_config): + """Set up the Geofency component.""" + config = hass_config[DOMAIN] + mobile_beacons = config[CONF_MOBILE_BEACONS] + hass.data[DOMAIN] = [slugify(beacon) for beacon in mobile_beacons] + hass.http.register_view(GeofencyView(hass.data[DOMAIN])) + + hass.async_create_task( + async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) + ) + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.mobile_beacons = mobile_beacons + + async def post(self, request): + """Handle Geofency requests.""" + data = await request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return "Invalid data", HTTP_UNPROCESSABLE_ENTITY + + if self._is_mobile_beacon(data): + return await self._set_location(hass, data, None) + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + if ATTR_CURRENT_LATITUDE in data: + data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] + data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] + + return await self._set_location(hass, data, location_name) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return {} + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] + + for attribute in gps_attributes: + if attribute in data: + data[attribute] = float(data[attribute]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + return data['device'] + + async def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name, + data + ) + + return "Setting location for {}".format(device) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index dcd66ed2a7c..eaf17fb53f4 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1322,19 +1322,19 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): mock_component(self.hass, 'group') mock_component(self.hass, 'zone') - patch_load = patch( + self.patch_load = patch( 'homeassistant.components.device_tracker.async_load_config', return_value=mock_coro([])) - patch_load.start() - self.addCleanup(patch_load.stop) + self.patch_load.start() - patch_save = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - patch_save.start() - self.addCleanup(patch_save.stop) + self.patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + self.patch_save.start() def teardown_method(self, method): """Tear down resources.""" + self.patch_load.stop() + self.patch_save.stop() self.hass.stop() @patch('homeassistant.components.device_tracker.owntracks.get_cipher', diff --git a/tests/components/geofency/__init__.py b/tests/components/geofency/__init__.py new file mode 100644 index 00000000000..12313e062db --- /dev/null +++ b/tests/components/geofency/__init__.py @@ -0,0 +1 @@ +"""Tests for the Geofency component.""" diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/geofency/test_init.py similarity index 82% rename from tests/components/device_tracker/test_geofency.py rename to tests/components/geofency/test_init.py index d84940d9fbf..442660c2daf 100644 --- a/tests/components/device_tracker/test_geofency.py +++ b/tests/components/geofency/test_init.py @@ -1,16 +1,14 @@ """The tests for the Geofency device tracker platform.""" # pylint: disable=redefined-outer-name -import asyncio from unittest.mock import patch import pytest from homeassistant.components import zone -import homeassistant.components.device_tracker as device_tracker -from homeassistant.components.device_tracker.geofency import ( - CONF_MOBILE_BEACONS, URL) +from homeassistant.components.geofency import ( + CONF_MOBILE_BEACONS, URL, DOMAIN) from homeassistant.const import ( - CONF_PLATFORM, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, + HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) from homeassistant.setup import async_setup_component from homeassistant.util import slugify @@ -110,9 +108,8 @@ BEACON_EXIT_CAR = { def geofency_client(loop, hass, aiohttp_client): """Geofency mock client.""" assert loop.run_until_complete(async_setup_component( - hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'geofency', + hass, DOMAIN, { + DOMAIN: { CONF_MOBILE_BEACONS: ['Car 1'] }})) @@ -133,11 +130,10 @@ def setup_zones(loop, hass): }})) -@asyncio.coroutine -def test_data_validation(geofency_client): +async def test_data_validation(geofency_client): """Test data validation.""" # No data - req = yield from geofency_client.post(URL) + req = await geofency_client.post(URL) assert req.status == HTTP_UNPROCESSABLE_ENTITY missing_attributes = ['address', 'device', @@ -147,15 +143,15 @@ def test_data_validation(geofency_client): for attribute in missing_attributes: copy = GPS_ENTER_HOME.copy() del copy[attribute] - req = yield from geofency_client.post(URL, data=copy) + req = await geofency_client.post(URL, data=copy) assert req.status == HTTP_UNPROCESSABLE_ENTITY -@asyncio.coroutine -def test_gps_enter_and_exit_home(hass, geofency_client): +async def test_gps_enter_and_exit_home(hass, geofency_client): """Test GPS based zone enter and exit.""" # Enter the Home zone - req = yield from geofency_client.post(URL, data=GPS_ENTER_HOME) + req = await geofency_client.post(URL, data=GPS_ENTER_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_ENTER_HOME['device']) state_name = hass.states.get('{}.{}'.format( @@ -163,7 +159,8 @@ def test_gps_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = yield from geofency_client.post(URL, data=GPS_EXIT_HOME) + req = await geofency_client.post(URL, data=GPS_EXIT_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME['device']) state_name = hass.states.get('{}.{}'.format( @@ -175,7 +172,8 @@ def test_gps_enter_and_exit_home(hass, geofency_client): data['currentLatitude'] = NOT_HOME_LATITUDE data['currentLongitude'] = NOT_HOME_LONGITUDE - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME['device']) current_latitude = hass.states.get('{}.{}'.format( @@ -186,11 +184,11 @@ def test_gps_enter_and_exit_home(hass, geofency_client): assert NOT_HOME_LONGITUDE == current_longitude -@asyncio.coroutine -def test_beacon_enter_and_exit_home(hass, geofency_client): +async def test_beacon_enter_and_exit_home(hass, geofency_client): """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" # Enter the Home zone - req = yield from geofency_client.post(URL, data=BEACON_ENTER_HOME) + req = await geofency_client.post(URL, data=BEACON_ENTER_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) state_name = hass.states.get('{}.{}'.format( @@ -198,7 +196,8 @@ def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = yield from geofency_client.post(URL, data=BEACON_EXIT_HOME) + req = await geofency_client.post(URL, data=BEACON_EXIT_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) state_name = hass.states.get('{}.{}'.format( @@ -206,11 +205,11 @@ def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_NOT_HOME == state_name -@asyncio.coroutine -def test_beacon_enter_and_exit_car(hass, geofency_client): +async def test_beacon_enter_and_exit_car(hass, geofency_client): """Test use of mobile iBeacon.""" # Enter the Car away from Home zone - req = yield from geofency_client.post(URL, data=BEACON_ENTER_CAR) + req = await geofency_client.post(URL, data=BEACON_ENTER_CAR) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) state_name = hass.states.get('{}.{}'.format( @@ -218,7 +217,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_NOT_HOME == state_name # Exit the Car away from Home zone - req = yield from geofency_client.post(URL, data=BEACON_EXIT_CAR) + req = await geofency_client.post(URL, data=BEACON_EXIT_CAR) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) state_name = hass.states.get('{}.{}'.format( @@ -229,7 +229,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): data = BEACON_ENTER_CAR.copy() data['latitude'] = HOME_LATITUDE data['longitude'] = HOME_LONGITUDE - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(data['name'])) state_name = hass.states.get('{}.{}'.format( @@ -237,7 +238,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_HOME == state_name # Exit the Car in the Home zone - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(data['name'])) state_name = hass.states.get('{}.{}'.format( From c89dade6193bcdbfec877fdaff941cd7ca300892 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 6 Nov 2018 22:08:04 +0100 Subject: [PATCH 075/238] Migrate python-openzwave to homeassistant-pyozw (#18268) * Migrate python-openzwave to homeassistant-pyozw * Update requirements_all.txt * Fix requirements --- homeassistant/components/zwave/__init__.py | 2 +- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 74c5cdb52a9..a27d2112dcd 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -42,7 +42,7 @@ from .discovery_schemas import DISCOVERY_SCHEMAS from .util import (check_node_schema, check_value_schema, node_name, check_has_unique_id, is_node_parsed) -REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.11'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'homeassistant-pyozw==0.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 859be19f8d7..33bad26ab3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -481,6 +481,9 @@ holidays==0.9.8 # homeassistant.components.frontend home-assistant-frontend==20181103.1 +# homeassistant.components.zwave +homeassistant-pyozw==0.1.0 + # homeassistant.components.homekit_controller # homekit==0.10 @@ -1236,9 +1239,6 @@ python-wink==1.10.1 # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.1.4 -# homeassistant.components.zwave -python_openzwave==0.4.11 - # homeassistant.components.egardia pythonegardia==1.0.39 From 35ae85e14e42847dc07c9f336df0fc6fbb9bf417 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Wed, 7 Nov 2018 08:52:51 +0200 Subject: [PATCH 076/238] SMA Guard against older pysma (#18278) --- homeassistant/components/sensor/sma.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/sma.py b/homeassistant/components/sensor/sma.py index 3f17b4971ec..acf1ead186c 100644 --- a/homeassistant/components/sensor/sma.py +++ b/homeassistant/components/sensor/sma.py @@ -37,11 +37,11 @@ def _check_sensor_schema(conf): """Check sensors and attributes are valid.""" try: import pysma - except ImportError: + valid = [s.name for s in pysma.SENSORS] + except (ImportError, AttributeError): return conf - valid = list(conf[CONF_CUSTOM].keys()) - valid.extend([s.name for s in pysma.SENSORS]) + valid.extend(conf[CONF_CUSTOM].keys()) for sname, attrs in conf[CONF_SENSORS].items(): if sname not in valid: raise vol.Invalid("{} does not exist".format(sname)) From df3d82e0e35b40308905e350c22b6572e62bf433 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Wed, 7 Nov 2018 09:03:35 +0100 Subject: [PATCH 077/238] Use async_add_executor_job at the xiaomi_miio platforms (#18294) --- .../components/device_tracker/xiaomi_miio.py | 3 ++- homeassistant/components/fan/xiaomi_miio.py | 11 ++++++----- homeassistant/components/light/xiaomi_miio.py | 17 +++++++++-------- homeassistant/components/remote/xiaomi_miio.py | 6 +++--- homeassistant/components/sensor/xiaomi_miio.py | 2 +- homeassistant/components/switch/xiaomi_miio.py | 11 ++++++----- homeassistant/components/vacuum/xiaomi_miio.py | 3 ++- 7 files changed, 29 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index 1c02efe4489..1abd86ffd8a 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -61,7 +61,8 @@ class XiaomiMiioDeviceScanner(DeviceScanner): devices = [] try: - station_info = await self.hass.async_add_job(self.device.status) + station_info = \ + await self.hass.async_add_executor_job(self.device.status) _LOGGER.debug("Got new station info: %s", station_info) for device in station_info.associated_stations: diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index dab7f2ab9c3..3462b0bc1eb 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -51,7 +51,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'zhimi.airpurifier.mc1', 'zhimi.humidifier.v1', 'zhimi.humidifier.ca1', - 'zhimi.airfresh.va2']), + 'zhimi.airfresh.va2', + ]), }) ATTR_MODEL = 'model' @@ -494,7 +495,7 @@ class XiaomiGenericDevice(FanEntity): """Call a miio device command handling error messages.""" from miio import DeviceException try: - result = await self.hass.async_add_job( + result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs)) _LOGGER.debug("Response received from miio device: %s", result) @@ -598,7 +599,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): return try: - state = await self.hass.async_add_job( + state = await self.hass.async_add_executor_job( self._device.status) _LOGGER.debug("Got new state: %s", state) @@ -774,7 +775,7 @@ class XiaomiAirHumidifier(XiaomiGenericDevice): return try: - state = await self.hass.async_add_job(self._device.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -877,7 +878,7 @@ class XiaomiAirFresh(XiaomiGenericDevice): return try: - state = await self.hass.async_add_job( + state = await self.hass.async_add_executor_job( self._device.status) _LOGGER.debug("Got new state: %s", state) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index cc88dbfe29f..291d8eaa267 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -41,7 +41,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'philips.light.bulb', 'philips.light.candle', 'philips.light.candle2', - 'philips.light.mono1']), + 'philips.light.mono1', + ]), }) # The light does not accept cct values < 1 @@ -263,7 +264,7 @@ class XiaomiPhilipsAbstractLight(Light): """Call a light command handling error messages.""" from miio import DeviceException try: - result = await self.hass.async_add_job( + result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs)) _LOGGER.debug("Response received from light: %s", result) @@ -303,7 +304,7 @@ class XiaomiPhilipsAbstractLight(Light): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -331,7 +332,7 @@ class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -481,7 +482,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -541,7 +542,7 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -587,7 +588,7 @@ class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -715,7 +716,7 @@ class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 0f63357c0dc..915f38745a4 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -128,14 +128,14 @@ async def async_setup_platform(hass, config, async_add_entities, slot = service.data.get(CONF_SLOT, entity.slot) - await hass.async_add_job(device.learn, slot) + await hass.async_add_executor_job(device.learn, slot) timeout = service.data.get(CONF_TIMEOUT, entity.timeout) _LOGGER.info("Press the key you want Home Assistant to learn") start_time = utcnow() while (utcnow() - start_time) < timedelta(seconds=timeout): - message = await hass.async_add_job( + message = await hass.async_add_executor_job( device.read, slot) _LOGGER.debug("Message received from device: '%s'", message) @@ -148,7 +148,7 @@ async def async_setup_platform(hass, config, async_add_entities, if ('error' in message and message['error']['message'] == "learn timeout"): - await hass.async_add_job(device.learn, slot) + await hass.async_add_executor_job(device.learn, slot) await asyncio.sleep(1, loop=hass.loop) diff --git a/homeassistant/components/sensor/xiaomi_miio.py b/homeassistant/components/sensor/xiaomi_miio.py index 86ee2f8767c..dddf7b23922 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -142,7 +142,7 @@ class XiaomiAirQualityMonitor(Entity): from miio import DeviceException try: - state = await self.hass.async_add_job(self._device.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index d55b2301745..7e11f986b92 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -38,7 +38,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'zimi.powerstrip.v2', 'chuangmi.plug.m1', 'chuangmi.plug.v2', - 'chuangmi.plug.v3']), + 'chuangmi.plug.v3', + ]), }) ATTR_POWER = 'power' @@ -247,7 +248,7 @@ class XiaomiPlugGenericSwitch(SwitchDevice): """Call a plug command handling error messages.""" from miio import DeviceException try: - result = await self.hass.async_add_job( + result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs)) _LOGGER.debug("Response received from plug: %s", result) @@ -290,7 +291,7 @@ class XiaomiPlugGenericSwitch(SwitchDevice): return try: - state = await self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -366,7 +367,7 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -463,7 +464,7 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) self._available = True diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 2e25af36b11..a491b69ca2f 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -266,7 +266,8 @@ class MiroboVacuum(StateVacuumDevice): """Call a vacuum command handling error messages.""" from miio import DeviceException try: - await self.hass.async_add_job(partial(func, *args, **kwargs)) + await self.hass.async_add_executor_job( + partial(func, *args, **kwargs)) return True except DeviceException as exc: _LOGGER.error(mask_error, exc) From e698fc25534c278b2dec16d02a06cb79a8cc70aa Mon Sep 17 00:00:00 2001 From: mvn23 Date: Wed, 7 Nov 2018 09:55:22 +0100 Subject: [PATCH 078/238] Bump pyotgw to 0.3b1 (#18286) * Bump pyotgw to 0.3b1 * Update requirements_all.txt --- homeassistant/components/opentherm_gw/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 3cf66c72a3a..06dcd0e19b0 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -104,7 +104,7 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) -REQUIREMENTS = ['pyotgw==0.3b0'] +REQUIREMENTS = ['pyotgw==0.3b1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 33bad26ab3f..80862b0f604 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1057,7 +1057,7 @@ pyoppleio==1.0.5 pyota==2.0.5 # homeassistant.components.opentherm_gw -pyotgw==0.3b0 +pyotgw==0.3b1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp From 6935b62487ce87ae7a4eb3731e70a017e934be86 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 7 Nov 2018 03:55:55 -0500 Subject: [PATCH 079/238] Remove skipped device tracker tests (#18291) --- .../components/device_tracker/test_asuswrt.py | 107 -------- tests/components/device_tracker/test_ddwrt.py | 254 ------------------ 2 files changed, 361 deletions(-) delete mode 100644 tests/components/device_tracker/test_asuswrt.py delete mode 100644 tests/components/device_tracker/test_ddwrt.py diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py deleted file mode 100644 index 09f14dc9700..00000000000 --- a/tests/components/device_tracker/test_asuswrt.py +++ /dev/null @@ -1,107 +0,0 @@ -"""The tests for the ASUSWRT device tracker platform.""" -import os -from datetime import timedelta -import unittest -from unittest import mock - -from homeassistant.setup import setup_component -from homeassistant.components import device_tracker -from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_NEW_DEVICE_DEFAULTS, - CONF_AWAY_HIDE) -from homeassistant.components.device_tracker.asuswrt import ( - CONF_PROTOCOL, CONF_MODE, DOMAIN, CONF_PORT) -from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, - CONF_HOST) - -import pytest -from tests.common import ( - get_test_home_assistant, get_test_config_dir, assert_setup_component, - mock_component) - -FAKEFILE = None - -VALID_CONFIG_ROUTER_SSH = {DOMAIN: { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PROTOCOL: 'ssh', - CONF_MODE: 'router', - CONF_PORT: '22' -}} - - -def setup_module(): - """Set up the test module.""" - global FAKEFILE - FAKEFILE = get_test_config_dir('fake_file') - with open(FAKEFILE, 'w') as out: - out.write(' ') - - -def teardown_module(): - """Tear down the module.""" - os.remove(FAKEFILE) - - -@pytest.mark.skip( - reason="These tests are performing actual failing network calls. They " - "need to be cleaned up before they are re-enabled. They're frequently " - "failing in Travis.") -class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): - """Tests for the ASUSWRT device tracker platform.""" - - hass = None - - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_component(self.hass, 'zone') - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - def test_password_or_pub_key_required(self): - """Test creating an AsusWRT scanner without a pass or pubkey.""" - with assert_setup_component(0, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PROTOCOL: 'ssh' - }}) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', - return_value=mock.MagicMock()) - def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): - """Test creating an AsusWRT scanner with a password and no pubkey.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: { - CONF_TRACK_NEW: True, - CONF_AWAY_HIDE: False - } - } - } - - with assert_setup_component(1, DOMAIN): - assert setup_component(self.hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_MODE] = 'router' - conf_dict[DOMAIN][CONF_PROTOCOL] = 'ssh' - conf_dict[DOMAIN][CONF_PORT] = 22 - assert asuswrt_mock.call_count == 1 - assert asuswrt_mock.call_args == mock.call(conf_dict[DOMAIN]) diff --git a/tests/components/device_tracker/test_ddwrt.py b/tests/components/device_tracker/test_ddwrt.py deleted file mode 100644 index 457ef6b47d0..00000000000 --- a/tests/components/device_tracker/test_ddwrt.py +++ /dev/null @@ -1,254 +0,0 @@ -"""The tests for the DD-WRT device tracker platform.""" -import os -import unittest -from unittest import mock -import logging -import re -import requests -import requests_mock - -import pytest - -from homeassistant import config -from homeassistant.setup import setup_component -from homeassistant.components import device_tracker -from homeassistant.const import ( - CONF_PLATFORM, CONF_HOST, CONF_PASSWORD, CONF_USERNAME) -from homeassistant.components.device_tracker import DOMAIN -from homeassistant.util import slugify - -from tests.common import ( - get_test_home_assistant, assert_setup_component, load_fixture, - mock_component) - -from ...test_util.aiohttp import mock_aiohttp_client - -TEST_HOST = '127.0.0.1' -_LOGGER = logging.getLogger(__name__) - - -@pytest.mark.skip -class TestDdwrt(unittest.TestCase): - """Tests for the Ddwrt device tracker platform.""" - - hass = None - - def run(self, result=None): - """Mock out http calls to macvendor API for whole test suite.""" - with mock_aiohttp_client() as aioclient_mock: - macvendor_re = re.compile('http://api.macvendors.com/.*') - aioclient_mock.get(macvendor_re, text='') - super().run(result) - - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_component(self.hass, 'zone') - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error') - def test_login_failed(self, mock_error): - """Create a Ddwrt scanner with wrong credentials.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - status_code=401) - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - - assert 'Failed to authenticate' in \ - str(mock_error.call_args_list[-1]) - - @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error') - def test_invalid_response(self, mock_error): - """Test error handling when response has an error status.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - status_code=444) - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - - assert 'Invalid response from DD-WRT' in \ - str(mock_error.call_args_list[-1]) - - @mock.patch('homeassistant.components.device_tracker._LOGGER.error') - @mock.patch('homeassistant.components.device_tracker.' - 'ddwrt.DdWrtDeviceScanner.get_ddwrt_data', return_value=None) - def test_no_response(self, data_mock, error_mock): - """Create a Ddwrt scanner with no response in init, should fail.""" - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - assert 'Error setting up platform' in \ - str(error_mock.call_args_list[-1]) - - @mock.patch('homeassistant.components.device_tracker.ddwrt.requests.get', - side_effect=requests.exceptions.Timeout) - @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error') - def test_get_timeout(self, mock_error, mock_request): - """Test get Ddwrt data with request time out.""" - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - - assert 'Connection to the router timed out' in \ - str(mock_error.call_args_list[-1]) - - def test_scan_devices(self): - """Test creating device info (MAC, name) from response. - - The created known_devices.yaml device info is compared - to the DD-WRT Lan Status request response fixture. - This effectively checks the data parsing functions. - """ - status_lan = load_fixture('Ddwrt_Status_Lan.txt') - - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Wireless.txt')) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=status_lan) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - self.hass.block_till_done() - - path = self.hass.config.path(device_tracker.YAML_DEVICES) - devices = config.load_yaml_config_file(path) - for device in devices: - assert devices[device]['mac'] in status_lan - assert slugify(devices[device]['name']) in status_lan - - def test_device_name_no_data(self): - """Test creating device info (MAC only) when no response.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Wireless.txt')) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, text=None) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - self.hass.block_till_done() - - path = self.hass.config.path(device_tracker.YAML_DEVICES) - devices = config.load_yaml_config_file(path) - status_lan = load_fixture('Ddwrt_Status_Lan.txt') - for device in devices: - _LOGGER.error(devices[device]) - assert devices[device]['mac'] in status_lan - - def test_device_name_no_dhcp(self): - """Test creating device info (MAC) when missing dhcp response.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Wireless.txt')) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Lan.txt'). - replace('dhcp_leases', 'missing')) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - self.hass.block_till_done() - - path = self.hass.config.path(device_tracker.YAML_DEVICES) - devices = config.load_yaml_config_file(path) - status_lan = load_fixture('Ddwrt_Status_Lan.txt') - for device in devices: - _LOGGER.error(devices[device]) - assert devices[device]['mac'] in status_lan - - def test_update_no_data(self): - """Test error handling of no response when active devices checked.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - # First request has to work to set up connection - [{'text': load_fixture('Ddwrt_Status_Wireless.txt')}, - # Second request to get active devices fails - {'text': None}]) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Lan.txt')) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - - def test_update_wrong_data(self): - """Test error handling of bad response when active devices checked.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Wireless.txt'). - replace('active_wireless', 'missing')) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Lan.txt')) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) From 0d7cb5487204e9b5016170ab8ea02d635598d4aa Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 7 Nov 2018 03:56:10 -0500 Subject: [PATCH 080/238] Update litejet automation tests to async (#18287) --- tests/components/automation/test_litejet.py | 406 ++++++++++---------- 1 file changed, 206 insertions(+), 200 deletions(-) diff --git a/tests/components/automation/test_litejet.py b/tests/components/automation/test_litejet.py index 3d88174708b..278fdab8f5f 100644 --- a/tests/components/automation/test_litejet.py +++ b/tests/components/automation/test_litejet.py @@ -1,15 +1,15 @@ """The tests for the litejet component.""" import logging -import unittest from unittest import mock from datetime import timedelta +import pytest from homeassistant import setup import homeassistant.util.dt as dt_util from homeassistant.components import litejet import homeassistant.components.automation as automation -from tests.common import (fire_time_changed, get_test_home_assistant) +from tests.common import (async_fire_time_changed, async_mock_service) _LOGGER = logging.getLogger(__name__) @@ -19,238 +19,244 @@ ENTITY_OTHER_SWITCH = 'switch.mock_switch_2' ENTITY_OTHER_SWITCH_NUMBER = 2 -class TestLiteJetTrigger(unittest.TestCase): - """Test the litejet component.""" +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') - @mock.patch('pylitejet.LiteJet') - def setup_method(self, method, mock_pylitejet): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.start() - self.switch_pressed_callbacks = {} - self.switch_released_callbacks = {} - self.calls = [] +def get_switch_name(number): + """Get a mock switch name.""" + return "Mock Switch #"+str(number) - def get_switch_name(number): - return "Mock Switch #"+str(number) + +@pytest.fixture +def mock_lj(hass): + """Initialize components.""" + with mock.patch('pylitejet.LiteJet') as mock_pylitejet: + mock_lj = mock_pylitejet.return_value + + mock_lj.switch_pressed_callbacks = {} + mock_lj.switch_released_callbacks = {} def on_switch_pressed(number, callback): - self.switch_pressed_callbacks[number] = callback + mock_lj.switch_pressed_callbacks[number] = callback def on_switch_released(number, callback): - self.switch_released_callbacks[number] = callback + mock_lj.switch_released_callbacks[number] = callback - def record_call(service): - self.calls.append(service) - - self.mock_lj = mock_pylitejet.return_value - self.mock_lj.loads.return_value = range(0) - self.mock_lj.button_switches.return_value = range(1, 3) - self.mock_lj.all_switches.return_value = range(1, 6) - self.mock_lj.scenes.return_value = range(0) - self.mock_lj.get_switch_name.side_effect = get_switch_name - self.mock_lj.on_switch_pressed.side_effect = on_switch_pressed - self.mock_lj.on_switch_released.side_effect = on_switch_released + mock_lj.loads.return_value = range(0) + mock_lj.button_switches.return_value = range(1, 3) + mock_lj.all_switches.return_value = range(1, 6) + mock_lj.scenes.return_value = range(0) + mock_lj.get_switch_name.side_effect = get_switch_name + mock_lj.on_switch_pressed.side_effect = on_switch_pressed + mock_lj.on_switch_released.side_effect = on_switch_released config = { 'litejet': { 'port': '/tmp/this_will_be_mocked' } } - assert setup.setup_component(self.hass, litejet.DOMAIN, config) + assert hass.loop.run_until_complete(setup.async_setup_component( + hass, litejet.DOMAIN, config)) - self.hass.services.register('test', 'automation', record_call) + mock_lj.start_time = dt_util.utcnow() + mock_lj.last_delta = timedelta(0) + return mock_lj - self.hass.block_till_done() - self.start_time = dt_util.utcnow() - self.last_delta = timedelta(0) +async def simulate_press(hass, mock_lj, number): + """Test to simulate a press.""" + _LOGGER.info('*** simulate press of %d', number) + callback = mock_lj.switch_pressed_callbacks.get(number) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=mock_lj.start_time + mock_lj.last_delta): + if callback is not None: + await hass.async_add_job(callback) + await hass.async_block_till_done() - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - def simulate_press(self, number): - """Test to simulate a press.""" - _LOGGER.info('*** simulate press of %d', number) - callback = self.switch_pressed_callbacks.get(number) - with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=self.start_time + self.last_delta): - if callback is not None: - callback() - self.hass.block_till_done() +async def simulate_release(hass, mock_lj, number): + """Test to simulate releasing.""" + _LOGGER.info('*** simulate release of %d', number) + callback = mock_lj.switch_released_callbacks.get(number) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=mock_lj.start_time + mock_lj.last_delta): + if callback is not None: + await hass.async_add_job(callback) + await hass.async_block_till_done() - def simulate_release(self, number): - """Test to simulate releasing.""" - _LOGGER.info('*** simulate release of %d', number) - callback = self.switch_released_callbacks.get(number) - with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=self.start_time + self.last_delta): - if callback is not None: - callback() - self.hass.block_till_done() - def simulate_time(self, delta): - """Test to simulate time.""" - _LOGGER.info( - '*** simulate time change by %s: %s', - delta, - self.start_time + delta) - self.last_delta = delta - with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=self.start_time + delta): - _LOGGER.info('now=%s', dt_util.utcnow()) - fire_time_changed(self.hass, self.start_time + delta) - self.hass.block_till_done() - _LOGGER.info('done with now=%s', dt_util.utcnow()) +async def simulate_time(hass, mock_lj, delta): + """Test to simulate time.""" + _LOGGER.info( + '*** simulate time change by %s: %s', + delta, + mock_lj.start_time + delta) + mock_lj.last_delta = delta + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=mock_lj.start_time + delta): + _LOGGER.info('now=%s', dt_util.utcnow()) + async_fire_time_changed(hass, mock_lj.start_time + delta) + await hass.async_block_till_done() + _LOGGER.info('done with now=%s', dt_util.utcnow()) - def setup_automation(self, trigger): - """Test setting up the automation.""" - assert setup.setup_component(self.hass, automation.DOMAIN, { - automation.DOMAIN: [ - { - 'alias': 'My Test', - 'trigger': trigger, - 'action': { - 'service': 'test.automation' - } + +async def setup_automation(hass, trigger): + """Test setting up the automation.""" + assert await setup.async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: [ + { + 'alias': 'My Test', + 'trigger': trigger, + 'action': { + 'service': 'test.automation' } - ] - }) - self.hass.block_till_done() - - def test_simple(self): - """Test the simplest form of a LiteJet trigger.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER - }) - - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - - assert len(self.calls) == 1 - - def test_held_more_than_short(self): - """Test a too short hold.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '200' } - }) + ] + }) + await hass.async_block_till_done() - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - self.simulate_time(timedelta(seconds=0.1)) - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - def test_held_more_than_long(self): - """Test a hold that is long enough.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '200' - } - }) +async def test_simple(hass, calls, mock_lj): + """Test the simplest form of a LiteJet trigger.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER + }) - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - self.simulate_time(timedelta(seconds=0.3)) - assert len(self.calls) == 1 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 1 + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) - def test_held_less_than_short(self): - """Test a hold that is short enough.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_less_than': { - 'milliseconds': '200' - } - }) + assert len(calls) == 1 - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - self.simulate_time(timedelta(seconds=0.1)) - assert len(self.calls) == 0 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 1 - def test_held_less_than_long(self): - """Test a hold that is too long.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_less_than': { - 'milliseconds': '200' - } - }) +async def test_held_more_than_short(hass, calls, mock_lj): + """Test a too short hold.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '200' + } + }) - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - self.simulate_time(timedelta(seconds=0.3)) - assert len(self.calls) == 0 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_lj, timedelta(seconds=0.1)) + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 - def test_held_in_range_short(self): - """Test an in-range trigger with a too short hold.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '100' - }, - 'held_less_than': { - 'milliseconds': '300' - } - }) - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - self.simulate_time(timedelta(seconds=0.05)) - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 +async def test_held_more_than_long(hass, calls, mock_lj): + """Test a hold that is long enough.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '200' + } + }) - def test_held_in_range_just_right(self): - """Test an in-range trigger with a just right hold.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '100' - }, - 'held_less_than': { - 'milliseconds': '300' - } - }) + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_lj, timedelta(seconds=0.3)) + assert len(calls) == 1 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 1 - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - self.simulate_time(timedelta(seconds=0.2)) - assert len(self.calls) == 0 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 1 - def test_held_in_range_long(self): - """Test an in-range trigger with a too long hold.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '100' - }, - 'held_less_than': { - 'milliseconds': '300' - } - }) +async def test_held_less_than_short(hass, calls, mock_lj): + """Test a hold that is short enough.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_less_than': { + 'milliseconds': '200' + } + }) - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - self.simulate_time(timedelta(seconds=0.4)) - assert len(self.calls) == 0 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_lj, timedelta(seconds=0.1)) + assert len(calls) == 0 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 1 + + +async def test_held_less_than_long(hass, calls, mock_lj): + """Test a hold that is too long.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_less_than': { + 'milliseconds': '200' + } + }) + + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_lj, timedelta(seconds=0.3)) + assert len(calls) == 0 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + + +async def test_held_in_range_short(hass, calls, mock_lj): + """Test an in-range trigger with a too short hold.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '100' + }, + 'held_less_than': { + 'milliseconds': '300' + } + }) + + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_lj, timedelta(seconds=0.05)) + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + + +async def test_held_in_range_just_right(hass, calls, mock_lj): + """Test an in-range trigger with a just right hold.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '100' + }, + 'held_less_than': { + 'milliseconds': '300' + } + }) + + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_lj, timedelta(seconds=0.2)) + assert len(calls) == 0 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 1 + + +async def test_held_in_range_long(hass, calls, mock_lj): + """Test an in-range trigger with a too long hold.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '100' + }, + 'held_less_than': { + 'milliseconds': '300' + } + }) + + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_lj, timedelta(seconds=0.4)) + assert len(calls) == 0 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 From ce069be16e0fb0d3e989aebae895dd0eb7ed2a9c Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 7 Nov 2018 03:56:24 -0500 Subject: [PATCH 081/238] Update manual ACP tests to async (#18289) --- .../components/alarm_control_panel/demo.py | 5 +- .../components/alarm_control_panel/common.py | 86 + .../alarm_control_panel/test_manual.py | 2593 +++++++++-------- 3 files changed, 1395 insertions(+), 1289 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index a3fbe49477e..fb4dccc1c86 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -13,9 +13,10 @@ from homeassistant.const import ( CONF_PENDING_TIME, CONF_TRIGGER_TIME) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Demo alarm control panel platform.""" - add_entities([ + async_add_entities([ manual.ManualAlarm(hass, 'Alarm', '1234', None, False, { STATE_ALARM_ARMED_AWAY: { CONF_DELAY_TIME: datetime.timedelta(seconds=0), diff --git a/tests/components/alarm_control_panel/common.py b/tests/components/alarm_control_panel/common.py index cf2de857076..829c05fef31 100644 --- a/tests/components/alarm_control_panel/common.py +++ b/tests/components/alarm_control_panel/common.py @@ -9,6 +9,21 @@ from homeassistant.const import ( SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS) from homeassistant.loader import bind_hass +from homeassistant.core import callback + + +@callback +@bind_hass +def async_alarm_disarm(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_DISARM, data)) @bind_hass @@ -23,6 +38,20 @@ def alarm_disarm(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data) +@callback +@bind_hass +def async_alarm_arm_home(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_ARM_HOME, data)) + + @bind_hass def alarm_arm_home(hass, code=None, entity_id=None): """Send the alarm the command for arm home.""" @@ -35,6 +64,20 @@ def alarm_arm_home(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data) +@callback +@bind_hass +def async_alarm_arm_away(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data)) + + @bind_hass def alarm_arm_away(hass, code=None, entity_id=None): """Send the alarm the command for arm away.""" @@ -47,6 +90,20 @@ def alarm_arm_away(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data) +@callback +@bind_hass +def async_alarm_arm_night(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data)) + + @bind_hass def alarm_arm_night(hass, code=None, entity_id=None): """Send the alarm the command for arm night.""" @@ -59,6 +116,20 @@ def alarm_arm_night(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data) +@callback +@bind_hass +def async_alarm_trigger(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_TRIGGER, data)) + + @bind_hass def alarm_trigger(hass, code=None, entity_id=None): """Send the alarm the command for trigger.""" @@ -71,6 +142,21 @@ def alarm_trigger(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_TRIGGER, data) +@callback +@bind_hass +def async_alarm_arm_custom_bypass(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_CUSTOM_BYPASS, data)) + + @bind_hass def alarm_arm_custom_bypass(hass, code=None, entity_id=None): """Send the alarm the command for arm custom bypass.""" diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index b39d4ecbbe9..36bae21dc32 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -1,16 +1,15 @@ """The tests for the manual Alarm Control Panel component.""" from datetime import timedelta -import unittest from unittest.mock import patch, MagicMock from homeassistant.components.alarm_control_panel import demo -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) from homeassistant.components import alarm_control_panel import homeassistant.util.dt as dt_util -from tests.common import (fire_time_changed, get_test_home_assistant, +from tests.common import (async_fire_time_changed, mock_component, mock_restore_cache) from tests.components.alarm_control_panel import common from homeassistant.core import State, CoreState @@ -18,1306 +17,1326 @@ from homeassistant.core import State, CoreState CODE = 'HELLO_CODE' -class TestAlarmControlPanelManual(unittest.TestCase): - """Test the manual alarm module.""" +async def test_setup_demo_platform(hass): + """Test setup.""" + mock = MagicMock() + add_entities = mock.MagicMock() + await demo.async_setup_platform(hass, {}, add_entities) + assert add_entities.call_count == 1 - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - def tearDown(self): # pylint: disable=invalid-name - """Stop down everything that was started.""" - self.hass.stop() +async def test_arm_home_no_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) - def test_setup_demo_platform(self): - """Test setup.""" - mock = MagicMock() - add_entities = mock.MagicMock() - demo.setup_platform(self.hass, {}, add_entities) - assert add_entities.call_count == 1 + entity_id = 'alarm_control_panel.test' - def test_arm_home_no_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state - entity_id = 'alarm_control_panel.test' + common.async_alarm_arm_home(hass, CODE) + await hass.async_block_till_done() - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state + assert STATE_ALARM_ARMED_HOME == \ + hass.states.get(entity_id).state - common.alarm_arm_home(self.hass, CODE) - self.hass.block_till_done() - assert STATE_ALARM_ARMED_HOME == \ - self.hass.states.get(entity_id).state +async def test_arm_home_with_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) - def test_arm_home_with_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = 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.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_HOME + + +async def test_arm_home_with_invalid_code(hass): + """Attempt to arm home without a valid code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, CODE + '2') + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_arm_away_no_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + +async def test_arm_home_with_template_code(hass): + """Attempt to arm with a template-based code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code_template': '{{ "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, 'abc') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_ARMED_HOME == state.state + + +async def test_arm_away_with_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = 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.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_AWAY + + +async def test_arm_away_with_invalid_code(hass): + """Attempt to arm away without a valid code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE + '2') + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_arm_night_no_pending(hass): + """Test arm night method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_night(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + hass.states.get(entity_id).state + + +async def test_arm_night_with_pending(hass): + """Test arm night method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_night(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = 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.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_NIGHT + + # Do not go to the pending state when updating to the same state + common.async_alarm_arm_night(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + hass.states.get(entity_id).state + + +async def test_arm_night_with_invalid_code(hass): + """Attempt to night home without a valid code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_night(hass, CODE + '2') + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_no_pending(hass): + """Test triggering when no pending submitted method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=60) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_TRIGGERED == \ + state.attributes['post_pending_state'] + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_TRIGGERED == state.state + + +async def test_trigger_zero_trigger_time(hass): + """Test disabled trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 0, + 'trigger_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_zero_trigger_time_with_pending(hass): + """Test disabled trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 3, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = 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.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = 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.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_DISARMED + + +async def test_trigger_with_unused_specific_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 5, + 'pending_time': 0, + 'armed_home': { + 'delay_time': 10 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_TRIGGERED == \ + state.attributes['post_pending_state'] + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + +async def test_trigger_with_specific_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_TRIGGERED == \ + state.attributes['post_pending_state'] + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + +async def test_trigger_with_pending_and_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + +async def test_trigger_with_pending_and_specific_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + +async def test_armed_home_with_specific_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_home': { + 'pending_time': 2 + } + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_arm_home(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_HOME == \ + hass.states.get(entity_id).state + + +async def test_armed_away_with_specific_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_away': { + 'pending_time': 2 + } + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_arm_away(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + +async def test_armed_night_with_specific_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_night': { + 'pending_time': 2 + } + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_arm_night(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_specific_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'triggered': { + 'pending_time': 2 + }, + 'trigger_time': 3, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_disarm_after_trigger(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': True + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_zero_specific_trigger_time(hass): + """Test trigger method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'disarmed': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_unused_zero_specific_trigger_time(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'armed_home': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_specific_trigger_time(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'disarmed': { + 'trigger_time': 5 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_no_disarm_after_trigger(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + +async def test_back_to_back_trigger_with_no_disarm_after_trigger(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + +async def test_disarm_while_pending_trigger(hass): + """Test disarming while pending state.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + common.async_alarm_disarm(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_disarm_during_trigger_with_invalid_code(hass): + """Test disarming while code is invalid.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 5, + 'code': CODE + '2', + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + common.async_alarm_disarm(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + +async def test_disarm_with_template_code(hass): + """Attempt to disarm with a valid or invalid template-based code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code_template': + '{{ "" if from_state == "disarmed" else "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, 'def') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_ARMED_HOME == state.state + + common.async_alarm_disarm(hass, 'def') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_ARMED_HOME == state.state + + common.async_alarm_disarm(hass, 'abc') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_DISARMED == state.state + + +async def test_arm_custom_bypass_no_pending(hass): + """Test arm custom bypass method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_custom_bypass(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ + hass.states.get(entity_id).state + + +async def test_arm_custom_bypass_with_pending(hass): + """Test arm custom bypass method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_custom_bypass(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == \ + STATE_ALARM_ARMED_CUSTOM_BYPASS + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS + + +async def test_arm_custom_bypass_with_invalid_code(hass): + """Attempt to custom bypass without a valid code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_custom_bypass(hass, CODE + '2') + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_armed_custom_bypass_with_specific_pending(hass): + """Test arm custom bypass method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_custom_bypass': { + 'pending_time': 2 + } + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_arm_custom_bypass(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ + hass.states.get(entity_id).state + + +async def test_arm_away_after_disabled_disarmed(hass): + """Test pending state with and without zero trigger time.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'delay_time': 1, + 'armed_away': { 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_home(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - 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.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - 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.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_home(self.hass, CODE + '2') - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_arm_away_no_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - def test_arm_home_with_template_code(self): - """Attempt to arm with a template-based code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code_template': '{{ "abc" }}', - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - self.hass.start() - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_home(self.hass, 'abc') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_ARMED_HOME == state.state - - def test_arm_away_with_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - 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.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - 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.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE + '2') - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_arm_night_no_pending(self): - """Test arm night method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_night(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_NIGHT == \ - self.hass.states.get(entity_id).state - - def test_arm_night_with_pending(self): - """Test arm night method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_night(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - 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.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_NIGHT - - # Do not go to the pending state when updating to the same state - common.alarm_arm_night(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_NIGHT == \ - self.hass.states.get(entity_id).state - - def test_arm_night_with_invalid_code(self): - """Attempt to night home without a valid code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_night(self.hass, CODE + '2') - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_no_pending(self): - """Test triggering when no pending submitted method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=60) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 1, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) + }, + 'disarmed': { + 'trigger_time': 0 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_DISARMED == \ + state.attributes['pre_pending_state'] + assert STATE_ALARM_ARMED_AWAY == \ + state.attributes['post_pending_state'] + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_DISARMED == \ + state.attributes['pre_pending_state'] + assert STATE_ALARM_ARMED_AWAY == \ + state.attributes['post_pending_state'] + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_ARMED_AWAY == state.state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_TRIGGERED == \ - state.attributes['post_pending_state'] - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_TRIGGERED == state.state - - def test_trigger_zero_trigger_time(self): - """Test disabled trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 0, - 'trigger_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_zero_trigger_time_with_pending(self): - """Test disabled trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 2, - 'trigger_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 2, - 'trigger_time': 3, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - 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.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - 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.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_DISARMED - - def test_trigger_with_unused_specific_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 5, - 'pending_time': 0, - 'armed_home': { - 'delay_time': 10 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_TRIGGERED == \ - state.attributes['post_pending_state'] - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - def test_trigger_with_specific_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 10, - 'pending_time': 0, - 'armed_away': { - 'delay_time': 1 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_TRIGGERED == \ - state.attributes['post_pending_state'] - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - def test_trigger_with_pending_and_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 1, - 'pending_time': 0, - 'triggered': { - 'pending_time': 1 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_PENDING - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_PENDING - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future += timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - def test_trigger_with_pending_and_specific_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 10, - 'pending_time': 0, - 'armed_away': { - 'delay_time': 1 - }, - 'triggered': { - 'pending_time': 1 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_PENDING - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_PENDING - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future += timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - def test_armed_home_with_specific_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'armed_home': { - 'pending_time': 2 - } - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_arm_home(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_HOME == \ - self.hass.states.get(entity_id).state - - def test_armed_away_with_specific_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'armed_away': { - 'pending_time': 2 - } - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_arm_away(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - def test_armed_night_with_specific_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'armed_night': { - 'pending_time': 2 - } - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_arm_night(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_NIGHT == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_specific_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'triggered': { - 'pending_time': 2 - }, - 'trigger_time': 3, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_disarm_after_trigger(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'pending_time': 0, - 'disarm_after_trigger': True - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_zero_specific_trigger_time(self): - """Test trigger method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'disarmed': { - 'trigger_time': 0 - }, - 'pending_time': 0, - 'disarm_after_trigger': True - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_unused_zero_specific_trigger_time(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'armed_home': { - 'trigger_time': 0 - }, - 'pending_time': 0, - 'disarm_after_trigger': True - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_specific_trigger_time(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'disarmed': { - 'trigger_time': 5 - }, - 'pending_time': 0, - 'disarm_after_trigger': True - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_no_disarm_after_trigger(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - def test_back_to_back_trigger_with_no_disarm_after_trigger(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - def test_disarm_while_pending_trigger(self): - """Test disarming while pending state.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - common.alarm_disarm(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_disarm_during_trigger_with_invalid_code(self): - """Test disarming while code is invalid.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 5, - 'code': CODE + '2', - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - common.alarm_disarm(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - def test_disarm_with_template_code(self): - """Attempt to disarm with a valid or invalid template-based code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code_template': - '{{ "" if from_state == "disarmed" else "abc" }}', - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - self.hass.start() - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_home(self.hass, 'def') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_ARMED_HOME == state.state - - common.alarm_disarm(self.hass, 'def') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_ARMED_HOME == state.state - - common.alarm_disarm(self.hass, 'abc') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_DISARMED == state.state - - def test_arm_custom_bypass_no_pending(self): - """Test arm custom bypass method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_custom_bypass(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ - self.hass.states.get(entity_id).state - - def test_arm_custom_bypass_with_pending(self): - """Test arm custom bypass method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_custom_bypass(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - state = self.hass.states.get(entity_id) - assert state.attributes['post_pending_state'] == \ - STATE_ALARM_ARMED_CUSTOM_BYPASS - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - def test_arm_custom_bypass_with_invalid_code(self): - """Attempt to custom bypass without a valid code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_custom_bypass(self.hass, CODE + '2') - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_armed_custom_bypass_with_specific_pending(self): - """Test arm custom bypass method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'armed_custom_bypass': { - 'pending_time': 2 - } - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_arm_custom_bypass(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ - self.hass.states.get(entity_id).state - - def test_arm_away_after_disabled_disarmed(self): - """Test pending state with and without zero trigger time.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'delay_time': 1, - 'armed_away': { - 'pending_time': 1, - }, - 'disarmed': { - 'trigger_time': 0 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_DISARMED == \ state.attributes['pre_pending_state'] - assert STATE_ALARM_ARMED_AWAY == \ + assert STATE_ALARM_TRIGGERED == \ state.attributes['post_pending_state'] - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_DISARMED == \ - state.attributes['pre_pending_state'] - assert STATE_ALARM_ARMED_AWAY == \ - state.attributes['post_pending_state'] - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_ARMED_AWAY == state.state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_ARMED_AWAY == \ - state.attributes['pre_pending_state'] - assert STATE_ALARM_TRIGGERED == \ - state.attributes['post_pending_state'] - - future += timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_TRIGGERED == state.state + state = hass.states.get(entity_id) + assert STATE_ALARM_TRIGGERED == state.state async def test_restore_armed_state(hass): From 65be458ce0265663477be6082341e60d2512b745 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 7 Nov 2018 03:56:32 -0500 Subject: [PATCH 082/238] Update input component tests to async (#18290) --- tests/components/test_input_boolean.py | 168 ++++------- tests/components/test_input_datetime.py | 44 +-- tests/components/test_input_number.py | 273 ++++++++--------- tests/components/test_input_select.py | 386 ++++++++++++------------ tests/components/test_input_text.py | 151 +++++---- 5 files changed, 473 insertions(+), 549 deletions(-) diff --git a/tests/components/test_input_boolean.py b/tests/components/test_input_boolean.py index 9fc9ceaefc1..a77e0a8c010 100644 --- a/tests/components/test_input_boolean.py +++ b/tests/components/test_input_boolean.py @@ -1,136 +1,98 @@ """The tests for the input_boolean component.""" # pylint: disable=protected-access import asyncio -import unittest import logging from homeassistant.core import CoreState, State, Context -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.components.input_boolean import ( is_on, CONF_INITIAL, DOMAIN) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON) -from homeassistant.loader import bind_hass -from tests.common import ( - get_test_home_assistant, mock_component, mock_restore_cache) +from tests.common import mock_component, mock_restore_cache _LOGGER = logging.getLogger(__name__) -@bind_hass -def toggle(hass, entity_id): - """Set input_boolean to False. +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] - This is a legacy helper method. Do not use it for new tests. - """ - hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}) + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) -@bind_hass -def turn_on(hass, entity_id): - """Set input_boolean to True. +async def test_methods(hass): + """Test is_on, turn_on, turn_off methods.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': None, + }}) + entity_id = 'input_boolean.test_1' - This is a legacy helper method. Do not use it for new tests. - """ - hass.services.call(DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}) + assert not is_on(hass, entity_id) + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}) + + await hass.async_block_till_done() + + assert is_on(hass, entity_id) + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}) + + await hass.async_block_till_done() + + assert not is_on(hass, entity_id) + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}) + + await hass.async_block_till_done() + + assert is_on(hass, entity_id) -@bind_hass -def turn_off(hass, entity_id): - """Set input_boolean to False. +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) - This is a legacy helper method. Do not use it for new tests. - """ - hass.services.call(DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}) + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': None, + 'test_2': { + 'name': 'Hello World', + 'icon': 'mdi:work', + 'initial': True, + }, + }}) -class TestInputBoolean(unittest.TestCase): - """Test the input boolean module.""" + _LOGGER.debug('ENTITIES: %s', hass.states.async_entity_ids()) - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert count_start + 2 == len(hass.states.async_entity_ids()) - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + state_1 = hass.states.get('input_boolean.test_1') + state_2 = hass.states.get('input_boolean.test_2') - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - 1, - {}, - {'name with space': None}, - ] + assert state_1 is not None + assert state_2 is not None - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + assert STATE_OFF == state_1.state + assert ATTR_ICON not in state_1.attributes + assert ATTR_FRIENDLY_NAME not in state_1.attributes - def test_methods(self): - """Test is_on, turn_on, turn_off methods.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': None, - }}) - entity_id = 'input_boolean.test_1' - - assert not is_on(self.hass, entity_id) - - turn_on(self.hass, entity_id) - - self.hass.block_till_done() - - assert is_on(self.hass, entity_id) - - turn_off(self.hass, entity_id) - - self.hass.block_till_done() - - assert not is_on(self.hass, entity_id) - - toggle(self.hass, entity_id) - - self.hass.block_till_done() - - assert is_on(self.hass, entity_id) - - 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()) - - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': None, - 'test_2': { - 'name': 'Hello World', - 'icon': 'mdi:work', - 'initial': True, - }, - }}) - - _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) - - assert count_start + 2 == len(self.hass.states.entity_ids()) - - state_1 = self.hass.states.get('input_boolean.test_1') - state_2 = self.hass.states.get('input_boolean.test_2') - - assert state_1 is not None - assert state_2 is not None - - assert STATE_OFF == state_1.state - assert ATTR_ICON not in state_1.attributes - assert ATTR_FRIENDLY_NAME not in state_1.attributes - - assert STATE_ON == state_2.state - assert 'Hello World' == \ - state_2.attributes.get(ATTR_FRIENDLY_NAME) - assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) + assert STATE_ON == state_2.state + assert 'Hello World' == \ + state_2.attributes.get(ATTR_FRIENDLY_NAME) + assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) @asyncio.coroutine diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py index e7f6b50c43d..9649531a8a1 100644 --- a/tests/components/test_input_datetime.py +++ b/tests/components/test_input_datetime.py @@ -1,15 +1,14 @@ """Tests for the Input slider component.""" # pylint: disable=protected-access import asyncio -import unittest import datetime from homeassistant.core import CoreState, State, Context -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.components.input_datetime import ( DOMAIN, ATTR_ENTITY_ID, ATTR_DATE, ATTR_TIME, SERVICE_SET_DATETIME) -from tests.common import get_test_home_assistant, mock_restore_cache +from tests.common import mock_restore_cache async def async_set_datetime(hass, entity_id, dt_value): @@ -21,32 +20,19 @@ async def async_set_datetime(hass, entity_id, dt_value): }, blocking=True) -class TestInputDatetime(unittest.TestCase): - """Test the input datetime component.""" - - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_invalid_configs(self): - """Test config.""" - invalid_configs = [ - None, - {}, - {'name with space': None}, - {'test_no_value': { - 'has_time': False, - 'has_date': False - }}, - ] - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) +async def test_invalid_configs(hass): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_no_value': { + 'has_time': False, + 'has_date': False + }}, + ] + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) @asyncio.coroutine diff --git a/tests/components/test_input_number.py b/tests/components/test_input_number.py index 3129b4445c7..354c67b4d1b 100644 --- a/tests/components/test_input_number.py +++ b/tests/components/test_input_number.py @@ -1,7 +1,6 @@ """The tests for the Input number component.""" # pylint: disable=protected-access import asyncio -import unittest from homeassistant.core import CoreState, State, Context from homeassistant.components.input_number import ( @@ -9,9 +8,9 @@ from homeassistant.components.input_number import ( SERVICE_SET_VALUE) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.loader import bind_hass -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant, mock_restore_cache +from tests.common import mock_restore_cache @bind_hass @@ -20,10 +19,11 @@ def set_value(hass, entity_id, value): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: entity_id, - ATTR_VALUE: value, - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SET_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + })) @bind_hass @@ -32,9 +32,10 @@ def increment(hass, entity_id): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_INCREMENT, { - ATTR_ENTITY_ID: entity_id - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, { + ATTR_ENTITY_ID: entity_id + })) @bind_hass @@ -43,152 +44,144 @@ def decrement(hass, entity_id): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_DECREMENT, { - ATTR_ENTITY_ID: entity_id - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, { + ATTR_ENTITY_ID: entity_id + })) -class TestInputNumber(unittest.TestCase): - """Test the input number component.""" +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_1': { + 'min': 50, + 'max': 50, + }}, + ] + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +async def test_set_value(hass): + """Test set_value method.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'initial': 50, + 'min': 0, + 'max': 100, + }, + }}) + entity_id = 'input_number.test_1' - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - {}, - {'name with space': None}, - {'test_1': { - 'min': 50, - 'max': 50, - }}, - ] - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + state = hass.states.get(entity_id) + assert 50 == float(state.state) - def test_set_value(self): - """Test set_value method.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'initial': 50, + set_value(hass, entity_id, '30.4') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 30.4 == float(state.state) + + set_value(hass, entity_id, '70') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 70 == float(state.state) + + set_value(hass, entity_id, '110') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 70 == float(state.state) + + +async def test_increment(hass): + """Test increment method.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_2': { + 'initial': 50, + 'min': 0, + 'max': 51, + }, + }}) + entity_id = 'input_number.test_2' + + state = hass.states.get(entity_id) + assert 50 == float(state.state) + + increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 51 == float(state.state) + + increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 51 == float(state.state) + + +async def test_decrement(hass): + """Test decrement method.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_3': { + 'initial': 50, + 'min': 49, + 'max': 100, + }, + }}) + entity_id = 'input_number.test_3' + + state = hass.states.get(entity_id) + assert 50 == float(state.state) + + decrement(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 49 == float(state.state) + + decrement(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 49 == float(state.state) + + +async def test_mode(hass): + """Test mode settings.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_default_slider': { 'min': 0, 'max': 100, }, - }}) - entity_id = 'input_number.test_1' - - state = self.hass.states.get(entity_id) - assert 50 == float(state.state) - - set_value(self.hass, entity_id, '30.4') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 30.4 == float(state.state) - - set_value(self.hass, entity_id, '70') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 70 == float(state.state) - - set_value(self.hass, entity_id, '110') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 70 == float(state.state) - - def test_increment(self): - """Test increment method.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_2': { - 'initial': 50, + 'test_explicit_box': { 'min': 0, - 'max': 51, - }, - }}) - entity_id = 'input_number.test_2' - - state = self.hass.states.get(entity_id) - assert 50 == float(state.state) - - increment(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 51 == float(state.state) - - increment(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 51 == float(state.state) - - def test_decrement(self): - """Test decrement method.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_3': { - 'initial': 50, - 'min': 49, 'max': 100, + 'mode': 'box', + }, + 'test_explicit_slider': { + 'min': 0, + 'max': 100, + 'mode': 'slider', }, }}) - entity_id = 'input_number.test_3' - state = self.hass.states.get(entity_id) - assert 50 == float(state.state) + state = hass.states.get('input_number.test_default_slider') + assert state + assert 'slider' == state.attributes['mode'] - decrement(self.hass, entity_id) - self.hass.block_till_done() + state = hass.states.get('input_number.test_explicit_box') + assert state + assert 'box' == state.attributes['mode'] - state = self.hass.states.get(entity_id) - assert 49 == float(state.state) - - decrement(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 49 == float(state.state) - - def test_mode(self): - """Test mode settings.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_default_slider': { - 'min': 0, - 'max': 100, - }, - 'test_explicit_box': { - 'min': 0, - 'max': 100, - 'mode': 'box', - }, - 'test_explicit_slider': { - 'min': 0, - 'max': 100, - 'mode': 'slider', - }, - }}) - - state = self.hass.states.get('input_number.test_default_slider') - assert state - assert 'slider' == state.attributes['mode'] - - state = self.hass.states.get('input_number.test_explicit_box') - assert state - assert 'box' == state.attributes['mode'] - - state = self.hass.states.get('input_number.test_explicit_slider') - assert state - assert 'slider' == state.attributes['mode'] + state = hass.states.get('input_number.test_explicit_slider') + assert state + assert 'slider' == state.attributes['mode'] @asyncio.coroutine diff --git a/tests/components/test_input_select.py b/tests/components/test_input_select.py index 684c526cbeb..f37566ffd73 100644 --- a/tests/components/test_input_select.py +++ b/tests/components/test_input_select.py @@ -1,7 +1,6 @@ """The tests for the Input select component.""" # pylint: disable=protected-access import asyncio -import unittest from homeassistant.loader import bind_hass from homeassistant.components.input_select import ( @@ -10,9 +9,9 @@ from homeassistant.components.input_select import ( from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON) from homeassistant.core import State, Context -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant, mock_restore_cache +from tests.common import mock_restore_cache @bind_hass @@ -21,10 +20,11 @@ def select_option(hass, entity_id, option): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SELECT_OPTION, { - ATTR_ENTITY_ID: entity_id, - ATTR_OPTION: option, - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SELECT_OPTION, { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: option, + })) @bind_hass @@ -33,9 +33,10 @@ def select_next(hass, entity_id): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SELECT_NEXT, { - ATTR_ENTITY_ID: entity_id, - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SELECT_NEXT, { + ATTR_ENTITY_ID: entity_id, + })) @bind_hass @@ -44,205 +45,198 @@ def select_previous(hass, entity_id): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SELECT_PREVIOUS, { - ATTR_ENTITY_ID: entity_id, + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SELECT_PREVIOUS, { + ATTR_ENTITY_ID: entity_id, + })) + + +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + # {'bad_options': {'options': None}}, + {'bad_initial': { + 'options': [1, 2], + 'initial': 3, + }}, + ] + + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + + +async def test_select_option(hass): + """Test select_option methods.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'options': [ + 'some option', + 'another option', + ], + }, + }}) + entity_id = 'input_select.test_1' + + state = hass.states.get(entity_id) + assert 'some option' == state.state + + select_option(hass, entity_id, 'another option') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'another option' == state.state + + select_option(hass, entity_id, 'non existing option') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'another option' == state.state + + +async def test_select_next(hass): + """Test select_next methods.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'options': [ + 'first option', + 'middle option', + 'last option', + ], + 'initial': 'middle option', + }, + }}) + entity_id = 'input_select.test_1' + + state = hass.states.get(entity_id) + assert 'middle option' == state.state + + select_next(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'last option' == state.state + + select_next(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'first option' == state.state + + +async def test_select_previous(hass): + """Test select_previous methods.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'options': [ + 'first option', + 'middle option', + 'last option', + ], + 'initial': 'middle option', + }, + }}) + entity_id = 'input_select.test_1' + + state = hass.states.get(entity_id) + assert 'middle option' == state.state + + select_previous(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'first option' == state.state + + select_previous(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'last option' == state.state + + +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) + + test_2_options = [ + 'Good Option', + 'Better Option', + 'Best Option', + ] + + assert await async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test_1': { + 'options': [ + 1, + 2, + ], + }, + 'test_2': { + 'name': 'Hello World', + 'icon': 'mdi:work', + 'options': test_2_options, + 'initial': 'Better Option', + }, + } }) + assert count_start + 2 == len(hass.states.async_entity_ids()) -class TestInputSelect(unittest.TestCase): - """Test the input select component.""" + state_1 = hass.states.get('input_select.test_1') + state_2 = hass.states.get('input_select.test_2') - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert state_1 is not None + assert state_2 is not None - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + assert '1' == state_1.state + assert ['1', '2'] == \ + state_1.attributes.get(ATTR_OPTIONS) + assert ATTR_ICON not in state_1.attributes - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - {}, - {'name with space': None}, - # {'bad_options': {'options': None}}, - {'bad_initial': { - 'options': [1, 2], - 'initial': 3, - }}, - ] + assert 'Better Option' == state_2.state + assert test_2_options == \ + state_2.attributes.get(ATTR_OPTIONS) + assert 'Hello World' == \ + state_2.attributes.get(ATTR_FRIENDLY_NAME) + assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) - def test_select_option(self): - """Test select_option methods.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'options': [ - 'some option', - 'another option', - ], - }, - }}) - entity_id = 'input_select.test_1' +async def test_set_options_service(hass): + """Test set_options service.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'options': [ + 'first option', + 'middle option', + 'last option', + ], + 'initial': 'middle option', + }, + }}) + entity_id = 'input_select.test_1' - state = self.hass.states.get(entity_id) - assert 'some option' == state.state + state = hass.states.get(entity_id) + assert 'middle option' == state.state - select_option(self.hass, entity_id, 'another option') - self.hass.block_till_done() + data = {ATTR_OPTIONS: ["test1", "test2"], "entity_id": entity_id} + await hass.services.async_call(DOMAIN, SERVICE_SET_OPTIONS, data) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 'another option' == state.state + state = hass.states.get(entity_id) + assert 'test1' == state.state - select_option(self.hass, entity_id, 'non existing option') - self.hass.block_till_done() + select_option(hass, entity_id, 'first option') + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert 'test1' == state.state - state = self.hass.states.get(entity_id) - assert 'another option' == state.state - - def test_select_next(self): - """Test select_next methods.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'options': [ - 'first option', - 'middle option', - 'last option', - ], - 'initial': 'middle option', - }, - }}) - entity_id = 'input_select.test_1' - - state = self.hass.states.get(entity_id) - assert 'middle option' == state.state - - select_next(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'last option' == state.state - - select_next(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'first option' == state.state - - def test_select_previous(self): - """Test select_previous methods.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'options': [ - 'first option', - 'middle option', - 'last option', - ], - 'initial': 'middle option', - }, - }}) - entity_id = 'input_select.test_1' - - state = self.hass.states.get(entity_id) - assert 'middle option' == state.state - - select_previous(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'first option' == state.state - - select_previous(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'last option' == state.state - - def test_config_options(self): - """Test configuration options.""" - count_start = len(self.hass.states.entity_ids()) - - test_2_options = [ - 'Good Option', - 'Better Option', - 'Best Option', - ] - - assert setup_component(self.hass, DOMAIN, { - DOMAIN: { - 'test_1': { - 'options': [ - 1, - 2, - ], - }, - 'test_2': { - 'name': 'Hello World', - 'icon': 'mdi:work', - 'options': test_2_options, - 'initial': 'Better Option', - }, - } - }) - - assert count_start + 2 == len(self.hass.states.entity_ids()) - - state_1 = self.hass.states.get('input_select.test_1') - state_2 = self.hass.states.get('input_select.test_2') - - assert state_1 is not None - assert state_2 is not None - - assert '1' == state_1.state - assert ['1', '2'] == \ - state_1.attributes.get(ATTR_OPTIONS) - assert ATTR_ICON not in state_1.attributes - - assert 'Better Option' == state_2.state - assert test_2_options == \ - state_2.attributes.get(ATTR_OPTIONS) - assert 'Hello World' == \ - state_2.attributes.get(ATTR_FRIENDLY_NAME) - assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) - - def test_set_options_service(self): - """Test set_options service.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'options': [ - 'first option', - 'middle option', - 'last option', - ], - 'initial': 'middle option', - }, - }}) - entity_id = 'input_select.test_1' - - state = self.hass.states.get(entity_id) - assert 'middle option' == state.state - - data = {ATTR_OPTIONS: ["test1", "test2"], "entity_id": entity_id} - self.hass.services.call(DOMAIN, SERVICE_SET_OPTIONS, data) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'test1' == state.state - - select_option(self.hass, entity_id, 'first option') - self.hass.block_till_done() - state = self.hass.states.get(entity_id) - assert 'test1' == state.state - - select_option(self.hass, entity_id, 'test2') - self.hass.block_till_done() - state = self.hass.states.get(entity_id) - assert 'test2' == state.state + select_option(hass, entity_id, 'test2') + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert 'test2' == state.state @asyncio.coroutine diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py index 110a3190b1f..7e8cec6ff80 100644 --- a/tests/components/test_input_text.py +++ b/tests/components/test_input_text.py @@ -1,16 +1,15 @@ """The tests for the Input text component.""" # pylint: disable=protected-access import asyncio -import unittest from homeassistant.components.input_text import ( ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import CoreState, State, Context from homeassistant.loader import bind_hass -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant, mock_restore_cache +from tests.common import mock_restore_cache @bind_hass @@ -19,98 +18,88 @@ def set_value(hass, entity_id, value): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: entity_id, - ATTR_VALUE: value, - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SET_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + })) -class TestInputText(unittest.TestCase): - """Test the input slider component.""" +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_1': { + 'min': 50, + 'max': 50, + }}, + ] + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +async def test_set_value(hass): + """Test set_value method.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'initial': 'test', + 'min': 3, + 'max': 10, + }, + }}) + entity_id = 'input_text.test_1' - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - {}, - {'name with space': None}, - {'test_1': { - 'min': 50, - 'max': 50, - }}, - ] - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + state = hass.states.get(entity_id) + assert 'test' == str(state.state) - def test_set_value(self): - """Test set_value method.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { + set_value(hass, entity_id, 'testing') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'testing' == str(state.state) + + set_value(hass, entity_id, 'testing too long') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'testing' == str(state.state) + + +async def test_mode(hass): + """Test mode settings.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_default_text': { 'initial': 'test', 'min': 3, 'max': 10, }, + 'test_explicit_text': { + 'initial': 'test', + 'min': 3, + 'max': 10, + 'mode': 'text', + }, + 'test_explicit_password': { + 'initial': 'test', + 'min': 3, + 'max': 10, + 'mode': 'password', + }, }}) - entity_id = 'input_text.test_1' - state = self.hass.states.get(entity_id) - assert 'test' == str(state.state) + state = hass.states.get('input_text.test_default_text') + assert state + assert 'text' == state.attributes['mode'] - set_value(self.hass, entity_id, 'testing') - self.hass.block_till_done() + state = hass.states.get('input_text.test_explicit_text') + assert state + assert 'text' == state.attributes['mode'] - state = self.hass.states.get(entity_id) - assert 'testing' == str(state.state) - - set_value(self.hass, entity_id, 'testing too long') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'testing' == str(state.state) - - def test_mode(self): - """Test mode settings.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_default_text': { - 'initial': 'test', - 'min': 3, - 'max': 10, - }, - 'test_explicit_text': { - 'initial': 'test', - 'min': 3, - 'max': 10, - 'mode': 'text', - }, - 'test_explicit_password': { - 'initial': 'test', - 'min': 3, - 'max': 10, - 'mode': 'password', - }, - }}) - - state = self.hass.states.get('input_text.test_default_text') - assert state - assert 'text' == state.attributes['mode'] - - state = self.hass.states.get('input_text.test_explicit_text') - assert state - assert 'text' == state.attributes['mode'] - - state = self.hass.states.get('input_text.test_explicit_password') - assert state - assert 'password' == state.attributes['mode'] + state = hass.states.get('input_text.test_explicit_password') + assert state + assert 'password' == state.attributes['mode'] @asyncio.coroutine From 5cee9942a6fe23a023abb64af25d008ea3238c6d Mon Sep 17 00:00:00 2001 From: Oleksii Serdiuk Date: Wed, 7 Nov 2018 10:01:05 +0100 Subject: [PATCH 083/238] Darksky: Add icon to summary sensors (#18275) The icon changes dynamically, based on summary. --- homeassistant/components/sensor/darksky.py | 35 +++++++++++++++------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index 9a3ba45dfa1..63744b7cd28 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -135,16 +135,26 @@ SENSOR_TYPES = { } CONDITION_PICTURES = { - 'clear-day': '/static/images/darksky/weather-sunny.svg', - 'clear-night': '/static/images/darksky/weather-night.svg', - 'rain': '/static/images/darksky/weather-pouring.svg', - 'snow': '/static/images/darksky/weather-snowy.svg', - 'sleet': '/static/images/darksky/weather-hail.svg', - 'wind': '/static/images/darksky/weather-windy.svg', - 'fog': '/static/images/darksky/weather-fog.svg', - 'cloudy': '/static/images/darksky/weather-cloudy.svg', - 'partly-cloudy-day': '/static/images/darksky/weather-partlycloudy.svg', - 'partly-cloudy-night': '/static/images/darksky/weather-cloudy.svg', + 'clear-day': ['/static/images/darksky/weather-sunny.svg', + 'mdi:weather-sunny'], + 'clear-night': ['/static/images/darksky/weather-night.svg', + 'mdi:weather-sunny'], + 'rain': ['/static/images/darksky/weather-pouring.svg', + 'mdi:weather-pouring'], + 'snow': ['/static/images/darksky/weather-snowy.svg', + 'mdi:weather-snowy'], + 'sleet': ['/static/images/darksky/weather-hail.svg', + 'mdi:weather-snowy-rainy'], + 'wind': ['/static/images/darksky/weather-windy.svg', + 'mdi:weather-windy'], + 'fog': ['/static/images/darksky/weather-fog.svg', + 'mdi:weather-fog'], + 'cloudy': ['/static/images/darksky/weather-cloudy.svg', + 'mdi:weather-cloudy'], + 'partly-cloudy-day': ['/static/images/darksky/weather-partlycloudy.svg', + 'mdi:weather-partlycloudy'], + 'partly-cloudy-night': ['/static/images/darksky/weather-cloudy.svg', + 'mdi:weather-partlycloudy'], } # Language Supported Codes @@ -259,7 +269,7 @@ class DarkSkySensor(Entity): return None if self._icon in CONDITION_PICTURES: - return CONDITION_PICTURES[self._icon] + return CONDITION_PICTURES[self._icon][0] return None @@ -277,6 +287,9 @@ class DarkSkySensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" + if 'summary' in self.type and self._icon in CONDITION_PICTURES: + return CONDITION_PICTURES[self._icon][1] + return SENSOR_TYPES[self.type][6] @property From 4287d1dd2d882d79cf472886fa6d6c64a50b6fb7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 7 Nov 2018 10:45:15 +0100 Subject: [PATCH 084/238] Bump frontend to 20181107.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 450daf5eb4c..2551ce8f6a9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181103.1'] +REQUIREMENTS = ['home-assistant-frontend==20181107.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 80862b0f604..1854fc0de98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -479,7 +479,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181103.1 +home-assistant-frontend==20181107.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42c80cea71b..90d13a8c3ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.6.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181103.1 +home-assistant-frontend==20181107.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 00c1b40940ca1b560102d341252a26498be81042 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 7 Nov 2018 10:45:29 +0100 Subject: [PATCH 085/238] Add translations --- .../components/deconz/.translations/sv.json | 2 +- .../dialogflow/.translations/zh-Hans.json | 13 ++++++++++ .../luftdaten/.translations/ca.json | 19 ++++++++++++++ .../luftdaten/.translations/en.json | 19 ++++++++++++++ .../luftdaten/.translations/no.json | 19 ++++++++++++++ .../luftdaten/.translations/zh-Hant.json | 19 ++++++++++++++ .../mailgun/.translations/zh-Hans.json | 11 ++++++++ .../twilio/.translations/zh-Hans.json | 7 ++++++ .../unifi/.translations/zh-Hans.json | 25 +++++++++++++++++++ .../components/upnp/.translations/lb.json | 1 + .../components/upnp/.translations/no.json | 1 + .../components/upnp/.translations/pl.json | 1 + .../components/upnp/.translations/pt.json | 1 + .../components/upnp/.translations/sl.json | 1 + .../upnp/.translations/zh-Hans.json | 1 + 15 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/dialogflow/.translations/zh-Hans.json create mode 100644 homeassistant/components/luftdaten/.translations/ca.json create mode 100644 homeassistant/components/luftdaten/.translations/en.json create mode 100644 homeassistant/components/luftdaten/.translations/no.json create mode 100644 homeassistant/components/luftdaten/.translations/zh-Hant.json create mode 100644 homeassistant/components/mailgun/.translations/zh-Hans.json create mode 100644 homeassistant/components/twilio/.translations/zh-Hans.json create mode 100644 homeassistant/components/unifi/.translations/zh-Hans.json diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json index 3ab3dae6dcd..88cf8742acd 100644 --- a/homeassistant/components/deconz/.translations/sv.json +++ b/homeassistant/components/deconz/.translations/sv.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "V\u00e4rd", - "port": "Port" + "port": "Port (standardv\u00e4rde: '80')" }, "title": "Definiera deCONZ-gatewaye" }, diff --git a/homeassistant/components/dialogflow/.translations/zh-Hans.json b/homeassistant/components/dialogflow/.translations/zh-Hans.json new file mode 100644 index 00000000000..6eecbed54ac --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Dialogflow \u7684 Webhook \u96c6\u6210]({dialogflow_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" + }, + "step": { + "user": { + "title": "\u8bbe\u7f6e Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/ca.json b/homeassistant/components/luftdaten/.translations/ca.json new file mode 100644 index 00000000000..1254b41bddf --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "No s'ha pogut comunicar amb l'API de Luftdaten", + "invalid_sensor": "El sensor no est\u00e0 disponible o no \u00e9s v\u00e0lid", + "sensor_exists": "Sensor ja registrat" + }, + "step": { + "user": { + "data": { + "show_on_map": "Mostra al mapa", + "station_id": "Identificador del sensor Luftdaten" + }, + "title": "Crear Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/en.json b/homeassistant/components/luftdaten/.translations/en.json new file mode 100644 index 00000000000..d6c86e9ac1f --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Unable to communicate with the Luftdaten API", + "invalid_sensor": "Sensor not available or invalid", + "sensor_exists": "Sensor already registered" + }, + "step": { + "user": { + "data": { + "show_on_map": "Show on map", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Define Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/no.json b/homeassistant/components/luftdaten/.translations/no.json new file mode 100644 index 00000000000..ac15a68bc4b --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Kan ikke kommunisere med Luftdaten API", + "invalid_sensor": "Sensor er ikke tilgjengelig eller ugyldig", + "sensor_exists": "Sensor er allerede registrert" + }, + "step": { + "user": { + "data": { + "show_on_map": "Vis p\u00e5 kart", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Definer Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/zh-Hant.json b/homeassistant/components/luftdaten/.translations/zh-Hant.json new file mode 100644 index 00000000000..5ea3f682631 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "\u7121\u6cd5\u8207 Luftdaten API \u9032\u884c\u901a\u4fe1", + "invalid_sensor": "\u7121\u6cd5\u4f7f\u7528\u6216\u7121\u6548\u7684\u611f\u61c9\u5668", + "sensor_exists": "\u611f\u61c9\u5668\u5df2\u8a3b\u518a" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u65bc\u5730\u5716\u986f\u793a", + "station_id": "Luftdaten \u611f\u61c9\u5668 ID" + }, + "title": "\u5b9a\u7fa9 Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/zh-Hans.json b/homeassistant/components/mailgun/.translations/zh-Hans.json new file mode 100644 index 00000000000..06c1d3624f4 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 Mailgun \u6d88\u606f\u3002", + "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + }, + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Mailgun \u7684 Webhook]({mailgun_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/zh-Hans.json b/homeassistant/components/twilio/.translations/zh-Hans.json new file mode 100644 index 00000000000..e108fe12498 --- /dev/null +++ b/homeassistant/components/twilio/.translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Twilio \u7684 Webhook]({twilio_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/zh-Hans.json b/homeassistant/components/unifi/.translations/zh-Hans.json new file mode 100644 index 00000000000..c8796536e2f --- /dev/null +++ b/homeassistant/components/unifi/.translations/zh-Hans.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u63a7\u5236\u5668\u7ad9\u70b9\u5df2\u914d\u7f6e\u5b8c\u6210", + "user_privilege": "\u7528\u6237\u987b\u4e3a\u7ba1\u7406\u5458" + }, + "error": { + "service_unavailable": "\u6ca1\u6709\u53ef\u7528\u7684\u670d\u52a1" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "site": "\u7ad9\u70b9 ID", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u4f7f\u7528\u6b63\u786e\u8bc1\u4e66\u7684\u63a7\u5236\u5668" + }, + "title": "\u914d\u7f6e UniFi \u63a7\u5236\u5668" + } + }, + "title": "UniFi \u63a7\u5236\u5668" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/lb.json b/homeassistant/components/upnp/.translations/lb.json index 1d13492a487..183144afb53 100644 --- a/homeassistant/components/upnp/.translations/lb.json +++ b/homeassistant/components/upnp/.translations/lb.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD ass scho konfigur\u00e9iert", + "incomplete_device": "Ignor\u00e9iert onvollst\u00e4nnegen UPnP-Apparat", "no_devices_discovered": "Keng UPnP/IGDs entdeckt", "no_sensors_or_port_mapping": "Aktiv\u00e9ier op mannst Sensoren oder Port Mapping" }, diff --git a/homeassistant/components/upnp/.translations/no.json b/homeassistant/components/upnp/.translations/no.json index a0c4c23f9c4..50b661627e3 100644 --- a/homeassistant/components/upnp/.translations/no.json +++ b/homeassistant/components/upnp/.translations/no.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP / IGD er allerede konfigurert", + "incomplete_device": "Ignorerer ufullstendig UPnP-enhet", "no_devices_discovered": "Ingen UPnP / IGDs oppdaget", "no_sensors_or_port_mapping": "Aktiver minst sensorer eller port mapping" }, diff --git a/homeassistant/components/upnp/.translations/pl.json b/homeassistant/components/upnp/.translations/pl.json index e47a25b9d93..d01946cb6e2 100644 --- a/homeassistant/components/upnp/.translations/pl.json +++ b/homeassistant/components/upnp/.translations/pl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD jest ju\u017c skonfigurowane", + "incomplete_device": "Ignorowanie niekompletnego urz\u0105dzenia UPnP", "no_devices_discovered": "Nie wykryto urz\u0105dze\u0144 UPnP/IGD", "no_sensors_or_port_mapping": "W\u0142\u0105cz przynajmniej sensory lub mapowanie port\u00f3w" }, diff --git a/homeassistant/components/upnp/.translations/pt.json b/homeassistant/components/upnp/.translations/pt.json index 899a5def479..99d056a7d78 100644 --- a/homeassistant/components/upnp/.translations/pt.json +++ b/homeassistant/components/upnp/.translations/pt.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD j\u00e1 est\u00e1 configurado", + "incomplete_device": "Dispositivos UPnP incompletos ignorados", "no_devices_discovered": "Nenhum UPnP/IGDs descoberto", "no_sensors_or_port_mapping": "Ative pelo menos os sensores ou o mapeamento de porta" }, diff --git a/homeassistant/components/upnp/.translations/sl.json b/homeassistant/components/upnp/.translations/sl.json index 20debe7f09a..f7052051192 100644 --- a/homeassistant/components/upnp/.translations/sl.json +++ b/homeassistant/components/upnp/.translations/sl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD je \u017ee konfiguriran", + "incomplete_device": "Ignoriranje nepopolnih UPnP naprav", "no_devices_discovered": "Ni odkritih UPnP/IGD naprav", "no_sensors_or_port_mapping": "Omogo\u010dite vsaj senzorje ali preslikavo vrat (port mapping)" }, diff --git a/homeassistant/components/upnp/.translations/zh-Hans.json b/homeassistant/components/upnp/.translations/zh-Hans.json index c4962ba1c4b..b16172e97d7 100644 --- a/homeassistant/components/upnp/.translations/zh-Hans.json +++ b/homeassistant/components/upnp/.translations/zh-Hans.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD \u5df2\u914d\u7f6e\u5b8c\u6210", + "incomplete_device": "\u5ffd\u7565\u4e0d\u5b8c\u6574\u7684 UPnP \u8bbe\u5907", "no_devices_discovered": "\u672a\u53d1\u73b0 UPnP/IGD", "no_sensors_or_port_mapping": "\u81f3\u5c11\u542f\u7528\u4f20\u611f\u5668\u6216\u7aef\u53e3\u6620\u5c04" }, From ec732c896d6f62c736a6440ad50e1966ace4e0c1 Mon Sep 17 00:00:00 2001 From: majuss Date: Wed, 7 Nov 2018 11:51:12 +0000 Subject: [PATCH 086/238] Add support for Lupusec alarm control panel (#17691) * Adds support for Lupusec alarm control panel * fixed various mostly cosmetic issues * fixed generic type of binary sensors * fixed some formatting; removed scan interval completely -> defaults now to 2 secs * removed unused data caches; added check if binary sensor class exists * cosmetics * generic type fix * small fix * small fixes * guard clause added * small fixes --- .coveragerc | 3 + .../components/alarm_control_panel/lupusec.py | 67 +++++++++++++ .../components/binary_sensor/lupusec.py | 53 +++++++++++ homeassistant/components/lupusec.py | 94 +++++++++++++++++++ homeassistant/components/switch/lupusec.py | 53 +++++++++++ requirements_all.txt | 3 + 6 files changed, 273 insertions(+) create mode 100644 homeassistant/components/alarm_control_panel/lupusec.py create mode 100644 homeassistant/components/binary_sensor/lupusec.py create mode 100644 homeassistant/components/lupusec.py create mode 100644 homeassistant/components/switch/lupusec.py diff --git a/.coveragerc b/.coveragerc index 1538435f564..eee5bcf8c49 100644 --- a/.coveragerc +++ b/.coveragerc @@ -203,6 +203,9 @@ omit = homeassistant/components/logi_circle.py homeassistant/components/*/logi_circle.py + homeassistant/components/lupusec.py + homeassistant/components/*/lupusec.py + homeassistant/components/lutron.py homeassistant/components/*/lutron.py diff --git a/homeassistant/components/alarm_control_panel/lupusec.py b/homeassistant/components/alarm_control_panel/lupusec.py new file mode 100644 index 00000000000..44d8a068ce2 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/lupusec.py @@ -0,0 +1,67 @@ +""" +This component provides HA alarm_control_panel support for Lupusec System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.lupusec/ +""" + +from datetime import timedelta + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.lupusec import DOMAIN as LUPUSEC_DOMAIN +from homeassistant.components.lupusec import LupusecDevice +from homeassistant.const import (STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED) + +DEPENDENCIES = ['lupusec'] + +ICON = 'mdi:security' + +SCAN_INTERVAL = timedelta(seconds=2) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an alarm control panel for a Lupusec device.""" + if discovery_info is None: + return + + data = hass.data[LUPUSEC_DOMAIN] + + alarm_devices = [LupusecAlarm(data, data.lupusec.get_alarm())] + + add_entities(alarm_devices) + + +class LupusecAlarm(LupusecDevice, AlarmControlPanel): + """An alarm_control_panel implementation for Lupusec.""" + + @property + def icon(self): + """Return the icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + if self._device.is_standby: + state = STATE_ALARM_DISARMED + elif self._device.is_away: + state = STATE_ALARM_ARMED_AWAY + elif self._device.is_home: + state = STATE_ALARM_ARMED_HOME + else: + state = None + return state + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._device.set_away() + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._device.set_standby() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._device.set_home() diff --git a/homeassistant/components/binary_sensor/lupusec.py b/homeassistant/components/binary_sensor/lupusec.py new file mode 100644 index 00000000000..df8210df026 --- /dev/null +++ b/homeassistant/components/binary_sensor/lupusec.py @@ -0,0 +1,53 @@ +""" +This component provides HA binary_sensor support for Lupusec Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.lupusec/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.lupusec import (LupusecDevice, + DOMAIN as LUPUSEC_DOMAIN) +from homeassistant.components.binary_sensor import (BinarySensorDevice, + DEVICE_CLASSES) + +DEPENDENCIES = ['lupusec'] + +SCAN_INTERVAL = timedelta(seconds=2) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a sensor for an Lupusec device.""" + if discovery_info is None: + return + + import lupupy.constants as CONST + + data = hass.data[LUPUSEC_DOMAIN] + + device_types = [CONST.TYPE_OPENING] + + devices = [] + for device in data.lupusec.get_devices(generic_type=device_types): + devices.append(LupusecBinarySensor(data, device)) + + add_entities(devices) + + +class LupusecBinarySensor(LupusecDevice, BinarySensorDevice): + """A binary sensor implementation for Lupusec device.""" + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._device.is_on + + @property + def device_class(self): + """Return the class of the binary sensor.""" + if self._device.generic_type not in DEVICE_CLASSES: + return None + return self._device.generic_type diff --git a/homeassistant/components/lupusec.py b/homeassistant/components/lupusec.py new file mode 100644 index 00000000000..162b49ef9b2 --- /dev/null +++ b/homeassistant/components/lupusec.py @@ -0,0 +1,94 @@ +""" +This component provides basic support for Lupusec Home Security system. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/lupusec +""" + +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, + CONF_NAME, CONF_IP_ADDRESS) +from homeassistant.helpers.entity import Entity +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['lupupy==0.0.10'] + +DOMAIN = 'lupusec' + +NOTIFICATION_ID = 'lupusec_notification' +NOTIFICATION_TITLE = 'Lupusec Security Setup' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + +LUPUSEC_PLATFORMS = [ + 'alarm_control_panel', 'binary_sensor', 'switch' +] + + +def setup(hass, config): + """Set up Lupusec component.""" + from lupupy.exceptions import LupusecException + + conf = config[DOMAIN] + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + ip_address = conf[CONF_IP_ADDRESS] + name = conf.get(CONF_NAME) + + try: + hass.data[DOMAIN] = LupusecSystem(username, password, ip_address, name) + except LupusecException as ex: + _LOGGER.error(ex) + + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for platform in LUPUSEC_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + return True + + +class LupusecSystem: + """Lupusec System class.""" + + def __init__(self, username, password, ip_address, name): + """Initialize the system.""" + import lupupy + self.lupusec = lupupy.Lupusec(username, password, ip_address) + self.name = name + + +class LupusecDevice(Entity): + """Representation of a Lupusec device.""" + + def __init__(self, data, device): + """Initialize a sensor for Lupusec device.""" + self._data = data + self._device = device + + def update(self): + """Update automation state.""" + self._device.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.name diff --git a/homeassistant/components/switch/lupusec.py b/homeassistant/components/switch/lupusec.py new file mode 100644 index 00000000000..35744160f24 --- /dev/null +++ b/homeassistant/components/switch/lupusec.py @@ -0,0 +1,53 @@ +""" +This component provides HA switch support for Lupusec Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.lupusec/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.lupusec import (LupusecDevice, + DOMAIN as LUPUSEC_DOMAIN) +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['lupusec'] + +SCAN_INTERVAL = timedelta(seconds=2) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Lupusec switch devices.""" + if discovery_info is None: + return + + import lupupy.constants as CONST + + data = hass.data[LUPUSEC_DOMAIN] + + devices = [] + + for device in data.lupusec.get_devices(generic_type=CONST.TYPE_SWITCH): + + devices.append(LupusecSwitch(data, device)) + + add_entities(devices) + + +class LupusecSwitch(LupusecDevice, SwitchDevice): + """Representation of a Lupusec switch.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on diff --git a/requirements_all.txt b/requirements_all.txt index 1854fc0de98..d391346710e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -596,6 +596,9 @@ logi_circle==0.1.7 # homeassistant.components.luftdaten luftdaten==0.3.4 +# homeassistant.components.lupusec +lupupy==0.0.10 + # homeassistant.components.light.lw12wifi lw12==0.9.2 From 29be78e08e81045b67d6784f0acb99e61daa2e4e Mon Sep 17 00:00:00 2001 From: Jorim Tielemans Date: Wed, 7 Nov 2018 13:28:25 +0100 Subject: [PATCH 087/238] Improve version sensor (#18292) * Validate value against valid list * Show correct name Constants in alphabetical order. Added default name when not showing the local/current version. * Add icon Icon was already defined but not set * Unnecessary "elif" after "return" (no-else-return) --- homeassistant/components/sensor/version.py | 33 ++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/version.py b/homeassistant/components/sensor/version.py index b71ae158181..7cf0a28eb95 100644 --- a/homeassistant/components/sensor/version.py +++ b/homeassistant/components/sensor/version.py @@ -20,11 +20,21 @@ REQUIREMENTS = ['pyhaversion==2.0.1'] _LOGGER = logging.getLogger(__name__) +ALL_IMAGES = [ + 'default', 'intel-nuc', 'qemux86', 'qemux86-64', 'qemuarm', + 'qemuarm-64', 'raspberrypi', 'raspberrypi2', 'raspberrypi3', + 'raspberrypi3-64', 'tinker', 'odroid-c2', 'odroid-xu' +] +ALL_SOURCES = [ + 'local', 'pypi', 'hassio', 'docker' +] + CONF_BETA = 'beta' CONF_IMAGE = 'image' DEFAULT_IMAGE = 'default' -DEFAULT_NAME = "Current Version" +DEFAULT_NAME_LATEST = "Latest Version" +DEFAULT_NAME_LOCAL = "Current Version" DEFAULT_SOURCE = 'local' ICON = 'mdi:package-up' @@ -33,11 +43,9 @@ TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BETA, default=False): cv.boolean, - vol.Optional(CONF_IMAGE, default=DEFAULT_IMAGE): vol.All(cv.string, - vol.Lower), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SOURCE, default=DEFAULT_SOURCE): vol.All(cv.string, - vol.Lower), + vol.Optional(CONF_IMAGE, default=DEFAULT_IMAGE): vol.In(ALL_IMAGES), + vol.Optional(CONF_NAME, default=''): cv.string, + vol.Optional(CONF_SOURCE, default=DEFAULT_SOURCE): vol.In(ALL_SOURCES), }) @@ -63,7 +71,7 @@ async def async_setup_platform( class VersionSensor(Entity): """Representation of a Home Assistant version sensor.""" - def __init__(self, haversion, name): + def __init__(self, haversion, name=''): """Initialize the Version sensor.""" self.haversion = haversion self._name = name @@ -76,7 +84,11 @@ class VersionSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return self._name + if self._name: + return self._name + if self.haversion.source == DEFAULT_SOURCE: + return DEFAULT_NAME_LOCAL + return DEFAULT_NAME_LATEST @property def state(self): @@ -88,6 +100,11 @@ class VersionSensor(Entity): """Return attributes for the sensor.""" return self.haversion.api.version_data + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + class VersionData: """Get the latest data and update the states.""" From f99701f41a59a1316877c9c9a90b53c40ddf971e Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 7 Nov 2018 14:30:41 +0200 Subject: [PATCH 088/238] Upgrade hdate to 0.7.5 (#18296) In 0.7.x the API to HDate was cleaned up so as to move logic from homeassistant to the HDate external library. This commit removes all the superfluous code, updates the required tests and changes the requirement from version 0.6.5 to 0.7.5 --- .../components/sensor/jewish_calendar.py | 26 ++++--------------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/sensor/test_jewish_calendar.py | 10 +++---- 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sensor/jewish_calendar.py b/homeassistant/components/sensor/jewish_calendar.py index 2c917ba0f3b..8058a411266 100644 --- a/homeassistant/components/sensor/jewish_calendar.py +++ b/homeassistant/components/sensor/jewish_calendar.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.sun import get_astral_event_date import homeassistant.util.dt as dt_util -REQUIREMENTS = ['hdate==0.6.5'] +REQUIREMENTS = ['hdate==0.7.5'] _LOGGER = logging.getLogger(__name__) @@ -113,7 +113,6 @@ class JewishCalSensor(Entity): _LOGGER.debug("Now: %s Timezone = %s", now, now.tzinfo) today = now.date() - upcoming_saturday = today + timedelta((12 - today.weekday()) % 7) sunset = dt_util.as_local(get_astral_event_date( self.hass, SUN_EVENT_SUNSET, today)) @@ -124,30 +123,15 @@ class JewishCalSensor(Entity): date = hdate.HDate( today, diaspora=self.diaspora, hebrew=self._hebrew) - upcoming_shabbat = hdate.HDate( - upcoming_saturday, diaspora=self.diaspora, hebrew=self._hebrew) if self.type == 'date': - self._state = hdate.date.get_hebrew_date( - date.h_day, date.h_month, date.h_year, hebrew=self._hebrew) + self._state = date.hebrew_date elif self.type == 'weekly_portion': - self._state = hdate.date.get_parashe( - upcoming_shabbat.get_reading(self.diaspora), - hebrew=self._hebrew) + self._state = date.parasha elif self.type == 'holiday_name': - try: - description = next( - x.description[self._hebrew] - for x in hdate.htables.HOLIDAYS - if x.index == date.get_holyday()) - if not self._hebrew: - self._state = description - else: - self._state = description.long - except StopIteration: - self._state = None + self._state = date.holiday_description elif self.type == 'holyness': - self._state = hdate.date.get_holyday_type(date.get_holyday()) + self._state = date.holiday_type else: times = hdate.Zmanim( date=today, latitude=self.latitude, longitude=self.longitude, diff --git a/requirements_all.txt b/requirements_all.txt index d391346710e..9af4c020892 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,7 +461,7 @@ hangups==0.4.6 hbmqtt==0.9.4 # homeassistant.components.sensor.jewish_calendar -hdate==0.6.5 +hdate==0.7.5 # homeassistant.components.climate.heatmiser heatmiserV3==0.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90d13a8c3ec..e2a64592498 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -91,7 +91,7 @@ hangups==0.4.6 hbmqtt==0.9.4 # homeassistant.components.sensor.jewish_calendar -hdate==0.6.5 +hdate==0.7.5 # homeassistant.components.binary_sensor.workday holidays==0.9.8 diff --git a/tests/components/sensor/test_jewish_calendar.py b/tests/components/sensor/test_jewish_calendar.py index 47874d1da42..320bc903661 100644 --- a/tests/components/sensor/test_jewish_calendar.py +++ b/tests/components/sensor/test_jewish_calendar.py @@ -64,7 +64,7 @@ class TestJewishCalenderSensor(): (dt(2018, 9, 3), 'UTC', 31.778, 35.235, "english", "date", False, "23 Elul 5778"), (dt(2018, 9, 3), 'UTC', 31.778, 35.235, "hebrew", "date", - False, "כ\"ג באלול ה\' תשע\"ח"), + False, "כ\"ג אלול ה\' תשע\"ח"), (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "hebrew", "holiday_name", False, "א\' ראש השנה"), (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "english", "holiday_name", @@ -72,17 +72,17 @@ class TestJewishCalenderSensor(): (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "english", "holyness", False, 1), (dt(2018, 9, 8), 'UTC', 31.778, 35.235, "hebrew", "weekly_portion", - False, "פרשת נצבים"), + False, "נצבים"), (dt(2018, 9, 8), 'America/New_York', 40.7128, -74.0060, "hebrew", "first_stars", True, time(19, 48)), (dt(2018, 9, 8), "Asia/Jerusalem", 31.778, 35.235, "hebrew", "first_stars", False, time(19, 21)), (dt(2018, 10, 14), "Asia/Jerusalem", 31.778, 35.235, "hebrew", - "weekly_portion", False, "פרשת לך לך"), + "weekly_portion", False, "לך לך"), (dt(2018, 10, 14, 17, 0, 0), "Asia/Jerusalem", 31.778, 35.235, - "hebrew", "date", False, "ה\' בחשון ה\' תשע\"ט"), + "hebrew", "date", False, "ה\' מרחשוון ה\' תשע\"ט"), (dt(2018, 10, 14, 19, 0, 0), "Asia/Jerusalem", 31.778, 35.235, - "hebrew", "date", False, "ו\' בחשון ה\' תשע\"ט") + "hebrew", "date", False, "ו\' מרחשוון ה\' תשע\"ט") ] test_ids = [ From d93716bd84b8cb64a17c2ea882f35946a1b175ce Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Wed, 7 Nov 2018 13:48:51 +0100 Subject: [PATCH 089/238] Add SUPPORT_SEEK for DLNA DMR devices + now (better) providing media_image_url for DLNA DMR devices (#18157) * Upgrade to async_upnp_client==0.13.2, now (better) providing media_image_url for DLNA DMR devices * Add SUPPORT_SEEK for DLNA DMR devices --- .../components/media_player/dlna_dmr.py | 19 ++++++++++++++++--- homeassistant/components/upnp/__init__.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py index 7e87925dcc7..5869cf2dbf9 100644 --- a/homeassistant/components/media_player/dlna_dmr.py +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_player.dlna_dmr/ """ import asyncio from datetime import datetime +from datetime import timedelta import functools import logging @@ -14,7 +15,7 @@ import voluptuous as vol from homeassistant.components.media_player import ( PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, - SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( CONF_NAME, CONF_URL, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, @@ -25,7 +26,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -REQUIREMENTS = ['async-upnp-client==0.13.0'] +REQUIREMENTS = ['async-upnp-client==0.13.1'] _LOGGER = logging.getLogger(__name__) @@ -233,6 +234,8 @@ class DlnaDmrDevice(MediaPlayerDevice): supported_features |= SUPPORT_NEXT_TRACK if self._device.has_play_media: supported_features |= SUPPORT_PLAY_MEDIA + if self._device.has_seek_rel_time: + supported_features |= SUPPORT_SEEK return supported_features @@ -284,6 +287,16 @@ class DlnaDmrDevice(MediaPlayerDevice): await self._device.async_stop() + @catch_request_errors() + async def async_media_seek(self, position): + """Send seek command.""" + if not self._device.can_seek_rel_time: + _LOGGER.debug('Cannot do Seek/rel_time') + return + + time = timedelta(seconds=position) + await self._device.async_seek_rel_time(time) + @catch_request_errors() async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" @@ -295,7 +308,7 @@ class DlnaDmrDevice(MediaPlayerDevice): if self._device.can_stop: await self.async_media_stop() - # +ueue media + # Queue media await self._device.async_set_transport_uri( media_id, title, mime_type, upnp_class) await self._device.async_wait_for_can_play() diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index e69943ae8b2..1651879fd2c 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -29,7 +29,7 @@ from .config_flow import async_ensure_domain_data from .device import Device -REQUIREMENTS = ['async-upnp-client==0.13.0'] +REQUIREMENTS = ['async-upnp-client==0.13.1'] NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP/IGD Setup' diff --git a/requirements_all.txt b/requirements_all.txt index 9af4c020892..361d84d9fc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -157,7 +157,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.upnp # homeassistant.components.media_player.dlna_dmr -async-upnp-client==0.13.0 +async-upnp-client==0.13.1 # homeassistant.components.axis axis==16 From aa4da479b522b91fbe4d550dd45f56b211d68e57 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Wed, 7 Nov 2018 18:32:13 +0100 Subject: [PATCH 090/238] Add upload and download sensors and component for asuswrt (#17757) * Adds upload and download sensors for asuswrt and makes it a component. * Rebase * removes warnings * Fixing review issues * More robust connection phase * Generate dependencies * Not needed try catch * Rename sensors * Revorked tests so they can be turned on again * Using component setup * Test through correct setup * Forgot we dont need to worry about older py --- homeassistant/components/asuswrt.py | 68 ++++++++++ .../components/device_tracker/asuswrt.py | 46 +------ homeassistant/components/sensor/asuswrt.py | 126 ++++++++++++++++++ requirements_all.txt | 4 +- .../components/device_tracker/test_asuswrt.py | 50 +++++++ 5 files changed, 252 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/asuswrt.py create mode 100644 homeassistant/components/sensor/asuswrt.py create mode 100644 tests/components/device_tracker/test_asuswrt.py diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py new file mode 100644 index 00000000000..37a55238e6b --- /dev/null +++ b/homeassistant/components/asuswrt.py @@ -0,0 +1,68 @@ +""" +Support for ASUSWRT devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/asuswrt/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, + CONF_PROTOCOL) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +REQUIREMENTS = ['aioasuswrt==1.1.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "asuswrt" +DATA_ASUSWRT = DOMAIN + +CONF_PUB_KEY = 'pub_key' +CONF_SSH_KEY = 'ssh_key' +CONF_REQUIRE_IP = 'require_ip' +DEFAULT_SSH_PORT = 22 +SECRET_GROUP = 'Password or SSH Key' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), + vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), + vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, + vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, + vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, + vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, + vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the asuswrt component.""" + from aioasuswrt.asuswrt import AsusWrt + conf = config[DOMAIN] + + api = AsusWrt(conf[CONF_HOST], conf.get(CONF_PORT), + conf.get(CONF_PROTOCOL) == 'telnet', + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ''), + conf.get('ssh_key', conf.get('pub_key', '')), + conf.get(CONF_MODE), conf.get(CONF_REQUIRE_IP)) + + await api.connection.async_connect() + if not api.is_connected: + _LOGGER.error("Unable to setup asuswrt component") + return False + + hass.data[DATA_ASUSWRT] = api + + hass.async_create_task(async_load_platform( + hass, 'sensor', DOMAIN, {}, config)) + hass.async_create_task(async_load_platform( + hass, 'device_tracker', DOMAIN, {}, config)) + return True diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 561d41562de..4630c3730ca 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -6,43 +6,17 @@ https://home-assistant.io/components/device_tracker.asuswrt/ """ import logging -import voluptuous as vol +from homeassistant.components.asuswrt import DATA_ASUSWRT +from homeassistant.components.device_tracker import DeviceScanner -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, CONF_PORT, CONF_MODE, - CONF_PROTOCOL) - -REQUIREMENTS = ['aioasuswrt==1.1.2'] +DEPENDENCIES = ['asuswrt'] _LOGGER = logging.getLogger(__name__) -CONF_PUB_KEY = 'pub_key' -CONF_SSH_KEY = 'ssh_key' -CONF_REQUIRE_IP = 'require_ip' -DEFAULT_SSH_PORT = 22 -SECRET_GROUP = 'Password or SSH Key' - -PLATFORM_SCHEMA = vol.All( - cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY), - PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), - vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), - vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, - vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, - vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, - vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, - vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile - })) - async def async_get_scanner(hass, config): """Validate the configuration and return an ASUS-WRT scanner.""" - scanner = AsusWrtDeviceScanner(config[DOMAIN]) + scanner = AsusWrtDeviceScanner(hass.data[DATA_ASUSWRT]) await scanner.async_connect() return scanner if scanner.success_init else None @@ -51,19 +25,11 @@ class AsusWrtDeviceScanner(DeviceScanner): """This class queries a router running ASUSWRT firmware.""" # Eighth attribute needed for mode (AP mode vs router mode) - def __init__(self, config): + def __init__(self, api): """Initialize the scanner.""" - from aioasuswrt.asuswrt import AsusWrt - self.last_results = {} self.success_init = False - self.connection = AsusWrt(config[CONF_HOST], config[CONF_PORT], - config[CONF_PROTOCOL] == 'telnet', - config[CONF_USERNAME], - config.get(CONF_PASSWORD, ''), - config.get('ssh_key', - config.get('pub_key', '')), - config[CONF_MODE], config[CONF_REQUIRE_IP]) + self.connection = api async def async_connect(self): """Initialize connection to the router.""" diff --git a/homeassistant/components/sensor/asuswrt.py b/homeassistant/components/sensor/asuswrt.py new file mode 100644 index 00000000000..4ca088fb1e2 --- /dev/null +++ b/homeassistant/components/sensor/asuswrt.py @@ -0,0 +1,126 @@ +""" +Asuswrt status sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.asuswrt/ +""" +import logging + +from homeassistant.helpers.entity import Entity +from homeassistant.components.asuswrt import DATA_ASUSWRT + +DEPENDENCIES = ['asuswrt'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, add_entities, discovery_info=None): + """Set up the asuswrt sensors.""" + api = hass.data[DATA_ASUSWRT] + add_entities([ + AsuswrtRXSensor(api), + AsuswrtTXSensor(api), + AsuswrtTotalRXSensor(api), + AsuswrtTotalTXSensor(api) + ]) + + +class AsuswrtSensor(Entity): + """Representation of a asuswrt sensor.""" + + _name = 'generic' + + def __init__(self, api): + """Initialize the sensor.""" + self._api = api + self._state = None + self._rates = None + self._speed = None + + @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 + + async def async_update(self): + """Fetch status from asuswrt.""" + self._rates = await self._api.async_get_packets_total() + self._speed = await self._api.async_get_current_transfer_rates() + + +class AsuswrtRXSensor(AsuswrtSensor): + """Representation of a asuswrt download speed sensor.""" + + _name = 'Asuswrt Download Speed' + _unit = 'Mbit/s' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._speed is not None: + self._state = round(self._speed[0] / 125000, 2) + + +class AsuswrtTXSensor(AsuswrtSensor): + """Representation of a asuswrt upload speed sensor.""" + + _name = 'Asuswrt Upload Speed' + _unit = 'Mbit/s' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._speed is not None: + self._state = round(self._speed[1] / 125000, 2) + + +class AsuswrtTotalRXSensor(AsuswrtSensor): + """Representation of a asuswrt total download sensor.""" + + _name = 'Asuswrt Download' + _unit = 'Gigabyte' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._rates is not None: + self._state = round(self._rates[0] / 1000000000, 1) + + +class AsuswrtTotalTXSensor(AsuswrtSensor): + """Representation of a asuswrt total upload sensor.""" + + _name = 'Asuswrt Upload' + _unit = 'Gigabyte' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._rates is not None: + self._state = round(self._rates[1] / 1000000000, 1) diff --git a/requirements_all.txt b/requirements_all.txt index 361d84d9fc8..ea278979078 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -85,8 +85,8 @@ abodepy==0.14.0 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.4 -# homeassistant.components.device_tracker.asuswrt -aioasuswrt==1.1.2 +# homeassistant.components.asuswrt +aioasuswrt==1.1.4 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py new file mode 100644 index 00000000000..e6f7a582e70 --- /dev/null +++ b/tests/components/device_tracker/test_asuswrt.py @@ -0,0 +1,50 @@ +"""The tests for the ASUSWRT device tracker platform.""" +from homeassistant.setup import async_setup_component + +from homeassistant.components.asuswrt import ( + CONF_PROTOCOL, CONF_MODE, DOMAIN, CONF_PORT, DATA_ASUSWRT) +from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, + CONF_HOST) + +from tests.common import MockDependency, mock_coro_func + +FAKEFILE = None + +VALID_CONFIG_ROUTER_SSH = {DOMAIN: { + CONF_PLATFORM: 'asuswrt', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PROTOCOL: 'ssh', + CONF_MODE: 'router', + CONF_PORT: '22' +}} + + +async def test_password_or_pub_key_required(hass): + """Test creating an AsusWRT scanner without a pass or pubkey.""" + with MockDependency('aioasuswrt.asuswrt')as mocked_asus: + mocked_asus.AsusWrt().connection.async_connect = mock_coro_func() + mocked_asus.AsusWrt().is_connected = False + result = await async_setup_component( + hass, DOMAIN, {DOMAIN: { + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user' + }}) + assert not result + + +async def test_get_scanner_with_password_no_pubkey(hass): + """Test creating an AsusWRT scanner with a password and no pubkey.""" + with MockDependency('aioasuswrt.asuswrt')as mocked_asus: + mocked_asus.AsusWrt().connection.async_connect = mock_coro_func() + mocked_asus.AsusWrt( + ).connection.async_get_connected_devices = mock_coro_func( + return_value={}) + result = await async_setup_component( + hass, DOMAIN, {DOMAIN: { + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: '4321' + }}) + assert result + assert hass.data[DATA_ASUSWRT] is not None From 0bf054fb593c4228005f92933e3b9357cde77524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 7 Nov 2018 19:54:33 +0100 Subject: [PATCH 091/238] Update pyruter to 1.1.0 to be able to reuse aiohttp session. (#18310) * Update pyruter to 1.1.0 to be able to reuse aiohttp session. * Taged correct version of pyruter. --- homeassistant/components/sensor/ruter.py | 6 ++++-- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/ruter.py b/homeassistant/components/sensor/ruter.py index 06514e35a54..ddad6a43c75 100644 --- a/homeassistant/components/sensor/ruter.py +++ b/homeassistant/components/sensor/ruter.py @@ -12,8 +12,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyruter==1.0.2'] +REQUIREMENTS = ['pyruter==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -41,7 +42,8 @@ async def async_setup_platform( name = config[CONF_NAME] offset = config[CONF_OFFSET] - ruter = Departures(hass.loop, stop_id, destination) + session = async_get_clientsession(hass) + ruter = Departures(hass.loop, stop_id, destination, session) sensor = [RuterSensor(ruter, name, offset)] async_add_entities(sensor, True) diff --git a/requirements_all.txt b/requirements_all.txt index ea278979078..e92e1d1e143 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1087,7 +1087,7 @@ pyrainbird==0.1.6 pyrecswitch==1.0.2 # homeassistant.components.sensor.ruter -pyruter==1.0.2 +pyruter==1.1.0 # homeassistant.components.sabnzbd pysabnzbd==1.1.0 From 5bab0018f524332b1cc4c8b71f1c37bb2689704f Mon Sep 17 00:00:00 2001 From: Jeff Wilson Date: Wed, 7 Nov 2018 15:52:55 -0500 Subject: [PATCH 092/238] Make flux switch async (#18277) * Make flux switch async * Fix line lengths * Fix indentation * Set tracker before await to avoid race condition * Fix lint errors --- homeassistant/components/switch/flux.py | 59 +++++++++++++------------ 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index ea7aded3e16..fdd0c09b9d7 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -19,7 +19,7 @@ from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.const import ( ATTR_ENTITY_ID, CONF_NAME, CONF_PLATFORM, CONF_LIGHTS, CONF_MODE, SERVICE_TURN_ON, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import slugify from homeassistant.util.color import ( @@ -67,7 +67,8 @@ PLATFORM_SCHEMA = vol.Schema({ }) -def set_lights_xy(hass, lights, x_val, y_val, brightness, transition): +async def async_set_lights_xy(hass, lights, x_val, y_val, brightness, + transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): @@ -79,11 +80,11 @@ def set_lights_xy(hass, lights, x_val, y_val, brightness, transition): service_data[ATTR_WHITE_VALUE] = brightness if transition is not None: service_data[ATTR_TRANSITION] = transition - hass.services.call( + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) -def set_lights_temp(hass, lights, mired, brightness, transition): +async def async_set_lights_temp(hass, lights, mired, brightness, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): @@ -94,11 +95,11 @@ def set_lights_temp(hass, lights, mired, brightness, transition): service_data[ATTR_BRIGHTNESS] = brightness if transition is not None: service_data[ATTR_TRANSITION] = transition - hass.services.call( + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) -def set_lights_rgb(hass, lights, rgb, transition): +async def async_set_lights_rgb(hass, lights, rgb, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): @@ -107,11 +108,12 @@ def set_lights_rgb(hass, lights, rgb, transition): service_data[ATTR_RGB_COLOR] = rgb if transition is not None: service_data[ATTR_TRANSITION] = transition - hass.services.call( + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Flux switches.""" name = config.get(CONF_NAME) lights = config.get(CONF_LIGHTS) @@ -129,14 +131,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): start_colortemp, sunset_colortemp, stop_colortemp, brightness, disable_brightness_adjust, mode, interval, transition) - add_entities([flux]) + async_add_entities([flux]) - def update(call=None): + async def async_update(call=None): """Update lights.""" - flux.flux_update() + await flux.async_flux_update() service_name = slugify("{} {}".format(name, 'update')) - hass.services.register(DOMAIN, service_name, update) + hass.services.async_register(DOMAIN, service_name, async_update) class FluxSwitch(SwitchDevice): @@ -172,30 +174,30 @@ class FluxSwitch(SwitchDevice): """Return true if switch is on.""" return self.unsub_tracker is not None - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn on flux.""" if self.is_on: return - # Make initial update - self.flux_update() - - self.unsub_tracker = track_time_interval( + self.unsub_tracker = async_track_time_interval( self.hass, - self.flux_update, + self.async_flux_update, datetime.timedelta(seconds=self._interval)) - self.schedule_update_ha_state() + # Make initial update + await self.async_flux_update() - def turn_off(self, **kwargs): + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): """Turn off flux.""" - if self.unsub_tracker is not None: + if self.is_on: self.unsub_tracker() self.unsub_tracker = None - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def flux_update(self, utcnow=None): + async def async_flux_update(self, utcnow=None): """Update all the lights using flux.""" if utcnow is None: utcnow = dt_utcnow() @@ -258,22 +260,23 @@ class FluxSwitch(SwitchDevice): if self._disable_brightness_adjust: brightness = None if self._mode == MODE_XY: - set_lights_xy(self.hass, self._lights, x_val, - y_val, brightness, self._transition) + await async_set_lights_xy(self.hass, self._lights, x_val, + y_val, brightness, self._transition) _LOGGER.info("Lights updated to x:%s y:%s brightness:%s, %s%% " "of %s cycle complete at %s", x_val, y_val, brightness, round( percentage_complete * 100), time_state, now) elif self._mode == MODE_RGB: - set_lights_rgb(self.hass, self._lights, rgb, self._transition) + await async_set_lights_rgb(self.hass, self._lights, rgb, + self._transition) _LOGGER.info("Lights updated to rgb:%s, %s%% " "of %s cycle complete at %s", rgb, round(percentage_complete * 100), time_state, now) else: # Convert to mired and clamp to allowed values mired = color_temperature_kelvin_to_mired(temp) - set_lights_temp(self.hass, self._lights, mired, brightness, - self._transition) + await async_set_lights_temp(self.hass, self._lights, mired, + brightness, self._transition) _LOGGER.info("Lights updated to mired:%s brightness:%s, %s%% " "of %s cycle complete at %s", mired, brightness, round(percentage_complete * 100), time_state, now) From e2fca0691e4d6b0990664d5b0c3e6ce6213f9a5d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 8 Nov 2018 00:33:51 +0100 Subject: [PATCH 093/238] Fix log error message (#18305) * Fix log error message * Update __init__.py --- homeassistant/components/hassio/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 9516675480a..8523bb5ea64 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -143,9 +143,10 @@ async def async_check_config(hass): result = await hassio.check_homeassistant_config() except HassioAPIError as err: _LOGGER.error("Error on Hass.io API: %s", err) + else: + if result['result'] == "error": + return result['message'] - if result['result'] == "error": - return result['message'] return None From 954191c385c3b4f62ecadc8b1af317f92fd92bf7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 7 Nov 2018 22:25:08 -0700 Subject: [PATCH 094/238] Add support for 17track.net package sensors (#18038) * Add support for 17track.net package sensors * Updated CODEOWNERS * Addressing comments * Fixed requirements * Member comments * Revert "Member comments" This reverts commit 61a19d79669b3ab21c48e0aede16228ec3792f60. * Member comments * Member comments --- .coveragerc | 3 +- CODEOWNERS | 1 + .../components/sensor/seventeentrack.py | 288 ++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sensor/seventeentrack.py diff --git a/.coveragerc b/.coveragerc index eee5bcf8c49..8d037f0c2e7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -580,7 +580,7 @@ omit = homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/lg_netcast.py - homeassistant/components/media_player/lg_soundbar.py + homeassistant/components/media_player/lg_soundbar.py homeassistant/components/media_player/liveboxplaytv.py homeassistant/components/media_player/mediaroom.py homeassistant/components/media_player/mpchc.py @@ -776,6 +776,7 @@ omit = homeassistant/components/sensor/sensehat.py homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/serial.py + homeassistant/components/sensor/seventeentrack.py homeassistant/components/sensor/sht31.py homeassistant/components/sensor/shodan.py homeassistant/components/sensor/sigfox.py diff --git a/CODEOWNERS b/CODEOWNERS index 0d498d89cae..9fc93b958e8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -120,6 +120,7 @@ homeassistant/components/sensor/pvoutput.py @fabaff homeassistant/components/sensor/qnap.py @colinodell homeassistant/components/sensor/scrape.py @fabaff homeassistant/components/sensor/serial.py @fabaff +homeassistant/components/sensor/seventeentrack.py @bachya homeassistant/components/sensor/shodan.py @fabaff homeassistant/components/sensor/sma.py @kellerza homeassistant/components/sensor/sql.py @dgomes diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py new file mode 100644 index 00000000000..7ad0e453760 --- /dev/null +++ b/homeassistant/components/sensor/seventeentrack.py @@ -0,0 +1,288 @@ +""" +Support for package tracking sensors from 17track.net. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.seventeentrack/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_PASSWORD, CONF_SCAN_INTERVAL, + CONF_USERNAME) +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle, slugify + +REQUIREMENTS = ['py17track==2.0.2'] +_LOGGER = logging.getLogger(__name__) + +ATTR_DESTINATION_COUNTRY = 'destination_country' +ATTR_INFO_TEXT = 'info_text' +ATTR_ORIGIN_COUNTRY = 'origin_country' +ATTR_PACKAGE_TYPE = 'package_type' +ATTR_TRACKING_INFO_LANGUAGE = 'tracking_info_language' + +CONF_SHOW_ARCHIVED = 'show_archived' +CONF_SHOW_DELIVERED = 'show_delivered' + +DATA_PACKAGES = 'package_data' +DATA_SUMMARY = 'summary_data' + +DEFAULT_ATTRIBUTION = 'Data provided by 17track.net' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) + +NOTIFICATION_DELIVERED_ID_SCAFFOLD = 'package_delivered_{0}' +NOTIFICATION_DELIVERED_TITLE = 'Package Delivered' +NOTIFICATION_DELIVERED_URL_SCAFFOLD = 'https://t.17track.net/track#nums={0}' + +VALUE_DELIVERED = 'Delivered' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SHOW_ARCHIVED, default=False): cv.boolean, + vol.Optional(CONF_SHOW_DELIVERED, default=False): cv.boolean, +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Configure the platform and add the sensors.""" + from py17track import Client + from py17track.errors import SeventeenTrackError + + websession = aiohttp_client.async_get_clientsession(hass) + + client = Client(websession) + + try: + login_result = await client.profile.login( + config[CONF_USERNAME], config[CONF_PASSWORD]) + + if not login_result: + _LOGGER.error('Invalid username and password provided') + return + except SeventeenTrackError as err: + _LOGGER.error('There was an error while logging in: %s', err) + return + + scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + data = SeventeenTrackData( + client, async_add_entities, scan_interval, config[CONF_SHOW_ARCHIVED], + config[CONF_SHOW_DELIVERED]) + await data.async_update() + + sensors = [] + + for status, quantity in data.summary.items(): + sensors.append(SeventeenTrackSummarySensor(data, status, quantity)) + + for package in data.packages: + sensors.append(SeventeenTrackPackageSensor(data, package)) + + async_add_entities(sensors, True) + + +class SeventeenTrackSummarySensor(Entity): + """Define a summary sensor.""" + + def __init__(self, data, status, initial_state): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._data = data + self._state = initial_state + self._status = status + + @property + def available(self): + """Return whether the entity is available.""" + return self._state is not None + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return 'mdi:package' + + @property + def name(self): + """Return the name.""" + return 'Packages {0}'.format(self._status) + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return 'summary_{0}_{1}'.format( + self._data.account_id, slugify(self._status)) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return 'packages' + + async def async_update(self): + """Update the sensor.""" + await self._data.async_update() + + self._state = self._data.summary.get(self._status) + + +class SeventeenTrackPackageSensor(Entity): + """Define an individual package sensor.""" + + def __init__(self, data, package): + """Initialize.""" + self._attrs = { + ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION, + ATTR_DESTINATION_COUNTRY: package.destination_country, + ATTR_INFO_TEXT: package.info_text, + ATTR_LOCATION: package.location, + ATTR_ORIGIN_COUNTRY: package.origin_country, + ATTR_PACKAGE_TYPE: package.package_type, + ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + } + self._data = data + self._state = package.status + self._tracking_number = package.tracking_number + + @property + def available(self): + """Return whether the entity is available.""" + return bool([ + p for p in self._data.packages + if p.tracking_number == self._tracking_number + ]) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return 'mdi:package' + + @property + def name(self): + """Return the name.""" + return self._tracking_number + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return 'package_{0}_{1}'.format( + self._data.account_id, self._tracking_number) + + async def async_update(self): + """Update the sensor.""" + await self._data.async_update() + + if not self._data.packages: + return + + try: + package = next(( + p for p in self._data.packages + if p.tracking_number == self._tracking_number)) + except StopIteration: + # If the package no longer exists in the data, log a message and + # delete this entity: + _LOGGER.info( + 'Deleting entity for stale package: %s', self._tracking_number) + self.hass.async_create_task(self.async_remove()) + return + + # If the user has elected to not see delivered packages and one gets + # delivered, post a notification and delete the entity: + if package.status == VALUE_DELIVERED and not self._data.show_delivered: + _LOGGER.info('Package delivered: %s', self._tracking_number) + self.hass.components.persistent_notification.create( + 'Package Delivered: {0}
' + 'Visit 17.track for more infomation: {1}' + ''.format( + self._tracking_number, + NOTIFICATION_DELIVERED_URL_SCAFFOLD.format( + self._tracking_number)), + title=NOTIFICATION_DELIVERED_TITLE, + notification_id=NOTIFICATION_DELIVERED_ID_SCAFFOLD.format( + self._tracking_number)) + self.hass.async_create_task(self.async_remove()) + return + + self._attrs.update({ + ATTR_INFO_TEXT: package.info_text, + ATTR_LOCATION: package.location, + }) + self._state = package.status + + +class SeventeenTrackData: + """Define a data handler for 17track.net.""" + + def __init__( + self, client, async_add_entities, scan_interval, show_archived, + show_delivered): + """Initialize.""" + self._async_add_entities = async_add_entities + self._client = client + self._scan_interval = scan_interval + self._show_archived = show_archived + self.account_id = client.profile.account_id + self.packages = [] + self.show_delivered = show_delivered + self.summary = {} + + self.async_update = Throttle(self._scan_interval)(self._async_update) + + async def _async_update(self): + """Get updated data from 17track.net.""" + from py17track.errors import SeventeenTrackError + + try: + packages = await self._client.profile.packages( + show_archived=self._show_archived) + _LOGGER.debug('New package data received: %s', packages) + + if not self.show_delivered: + packages = [p for p in packages if p.status != VALUE_DELIVERED] + + # Add new packages: + to_add = set(packages) - set(self.packages) + if self.packages and to_add: + self._async_add_entities([ + SeventeenTrackPackageSensor(self, package) + for package in to_add + ], True) + + self.packages = packages + except SeventeenTrackError as err: + _LOGGER.error('There was an error retrieving packages: %s', err) + self.packages = [] + + try: + self.summary = await self._client.profile.summary( + show_archived=self._show_archived) + _LOGGER.debug('New summary data received: %s', self.summary) + except SeventeenTrackError as err: + _LOGGER.error('There was an error retrieving the summary: %s', err) + self.summary = {} diff --git a/requirements_all.txt b/requirements_all.txt index e92e1d1e143..b8156d137a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -792,6 +792,9 @@ py-melissa-climate==2.0.0 # homeassistant.components.camera.synology py-synology==0.2.0 +# homeassistant.components.sensor.seventeentrack +py17track==2.0.2 + # homeassistant.components.hdmi_cec pyCEC==0.4.13 From 7fed49c4abf1215187c38e4a999e28f874f77f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 8 Nov 2018 09:27:51 +0100 Subject: [PATCH 095/238] Bump pyhaversion to 2.0.2 (#18318) --- homeassistant/components/sensor/version.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/version.py b/homeassistant/components/sensor/version.py index 7cf0a28eb95..afedd61326c 100644 --- a/homeassistant/components/sensor/version.py +++ b/homeassistant/components/sensor/version.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_NAME, CONF_SOURCE from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyhaversion==2.0.1'] +REQUIREMENTS = ['pyhaversion==2.0.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b8156d137a8..1f62dd3ef3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -941,7 +941,7 @@ pygtfs-homeassistant==0.1.3.dev0 pyharmony==1.0.20 # homeassistant.components.sensor.version -pyhaversion==2.0.1 +pyhaversion==2.0.2 # homeassistant.components.binary_sensor.hikvision pyhik==0.1.8 From 599542394a2942242cc6fc2731af8cf2902afff5 Mon Sep 17 00:00:00 2001 From: Nick Touran Date: Thu, 8 Nov 2018 00:39:35 -0800 Subject: [PATCH 096/238] Added optional precision configuration option to generic_thermostat. (#18317) * Added optional precision configuration option to generic_thermostat. * Added optional precision configuration option to generic_thermostat. * Style update. --- .../components/climate/generic_thermostat.py | 22 ++++++++++--- .../climate/test_generic_thermostat.py | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index d421157c2ec..212c4265d8a 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -17,7 +17,8 @@ from homeassistant.components.climate import ( SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID, - SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN) + SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN, PRECISION_HALVES, + PRECISION_TENTHS, PRECISION_WHOLE) from homeassistant.helpers import condition from homeassistant.helpers.event import ( async_track_state_change, async_track_time_interval) @@ -43,6 +44,7 @@ CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' CONF_AWAY_TEMP = 'away_temp' +CONF_PRECISION = 'precision' SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) @@ -63,7 +65,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ cv.time_period, cv.positive_timedelta), vol.Optional(CONF_INITIAL_OPERATION_MODE): vol.In([STATE_AUTO, STATE_OFF]), - vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float) + vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float), + vol.Optional(CONF_PRECISION): vol.In( + [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]), }) @@ -83,11 +87,13 @@ async def async_setup_platform(hass, config, async_add_entities, keep_alive = config.get(CONF_KEEP_ALIVE) initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE) away_temp = config.get(CONF_AWAY_TEMP) + precision = config.get(CONF_PRECISION) async_add_entities([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, - hot_tolerance, keep_alive, initial_operation_mode, away_temp)]) + hot_tolerance, keep_alive, initial_operation_mode, away_temp, + precision)]) class GenericThermostat(ClimateDevice): @@ -96,7 +102,7 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, hot_tolerance, keep_alive, - initial_operation_mode, away_temp): + initial_operation_mode, away_temp, precision): """Initialize the thermostat.""" self.hass = hass self._name = name @@ -109,6 +115,7 @@ class GenericThermostat(ClimateDevice): self._initial_operation_mode = initial_operation_mode self._saved_target_temp = target_temp if target_temp is not None \ else away_temp + self._temp_precision = precision if self.ac_mode: self._current_operation = STATE_COOL self._operation_list = [STATE_COOL, STATE_OFF] @@ -202,6 +209,13 @@ class GenericThermostat(ClimateDevice): """Return the name of the thermostat.""" return self._name + @property + def precision(self): + """Return the precision of the system.""" + if self._temp_precision is not None: + return self._temp_precision + return super().precision + @property def temperature_unit(self): """Return the unit of measurement.""" diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 3d30a21504a..8d2346260d9 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_OFF, STATE_IDLE, TEMP_CELSIUS, + TEMP_FAHRENHEIT, ATTR_TEMPERATURE ) from homeassistant import loader @@ -1074,6 +1075,37 @@ async def test_turn_off_when_off(hass, setup_comp_9): state_cool.attributes.get('operation_mode') +@pytest.fixture +def setup_comp_10(hass): + """Initialize components.""" + hass.config.temperature_unit = TEMP_FAHRENHEIT + assert hass.loop.run_until_complete(async_setup_component( + hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'cold_tolerance': 0.3, + 'hot_tolerance': 0.3, + 'target_temp': 25, + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_cycle_duration': datetime.timedelta(minutes=15), + 'keep_alive': datetime.timedelta(minutes=10), + 'precision': 0.1 + }})) + + +async def test_precision(hass, setup_comp_10): + """Test that setting precision to tenths works as intended.""" + common.async_set_operation_mode(hass, STATE_OFF) + await hass.async_block_till_done() + await hass.services.async_call('climate', SERVICE_TURN_OFF) + await hass.async_block_till_done() + common.async_set_temperature(hass, 23.27) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert 23.3 == state.attributes.get('temperature') + + async def test_custom_setup_params(hass): """Test the setup with custom parameters.""" result = await async_setup_component( From 54b0cde52ada7b477d59b1f59ead112b68693fd1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Nov 2018 11:17:44 +0100 Subject: [PATCH 097/238] Remove Velbus climate platform (#18319) --- homeassistant/components/climate/velbus.py | 81 ---------------------- homeassistant/components/velbus.py | 3 - 2 files changed, 84 deletions(-) delete mode 100644 homeassistant/components/climate/velbus.py diff --git a/homeassistant/components/climate/velbus.py b/homeassistant/components/climate/velbus.py deleted file mode 100644 index ab8542541c8..00000000000 --- a/homeassistant/components/climate/velbus.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -Support for Velbus thermostat. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/climate.velbus/ -""" -import logging - -from homeassistant.components.climate import ( - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) -from homeassistant.components.velbus import ( - DOMAIN as VELBUS_DOMAIN, VelbusEntity) -from homeassistant.const import ATTR_TEMPERATURE - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['velbus'] - -OPERATION_LIST = ['comfort', 'day', 'night', 'safe'] - -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the Velbus thermostat platform.""" - if discovery_info is None: - return - - sensors = [] - for sensor in discovery_info: - module = hass.data[VELBUS_DOMAIN].get_module(sensor[0]) - channel = sensor[1] - sensors.append(VelbusClimate(module, channel)) - - async_add_entities(sensors) - - -class VelbusClimate(VelbusEntity, ClimateDevice): - """Representation of a Velbus thermostat.""" - - @property - def supported_features(self): - """Return the list off supported features.""" - return SUPPORT_FLAGS - - @property - def temperature_unit(self): - """Return the unit this state is expressed in.""" - return self._module.get_unit(self._channel) - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._module.get_state(self._channel) - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return self._module.get_climate_mode() - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return OPERATION_LIST - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._module.get_climate_target() - - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" - self._module.set_mode(operation_mode) - self.schedule_update_ha_state() - - def set_temperature(self, **kwargs): - """Set new target temperatures.""" - if kwargs.get(ATTR_TEMPERATURE) is not None: - self._module.set_temp(kwargs.get(ATTR_TEMPERATURE)) - self.schedule_update_ha_state() diff --git a/homeassistant/components/velbus.py b/homeassistant/components/velbus.py index 294061527f8..a7b385297a8 100644 --- a/homeassistant/components/velbus.py +++ b/homeassistant/components/velbus.py @@ -47,7 +47,6 @@ async def async_setup(hass, config): modules = controller.get_modules() discovery_info = { 'switch': [], - 'climate': [], 'binary_sensor': [], 'sensor': [] } @@ -61,8 +60,6 @@ async def async_setup(hass, config): )) load_platform(hass, 'switch', DOMAIN, discovery_info['switch'], config) - load_platform(hass, 'climate', DOMAIN, - discovery_info['climate'], config) load_platform(hass, 'binary_sensor', DOMAIN, discovery_info['binary_sensor'], config) load_platform(hass, 'sensor', DOMAIN, From f7f0a4e8117496f8efbdef53de0c402515b75955 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Nov 2018 12:57:00 +0100 Subject: [PATCH 098/238] System groups (#18303) * Add read only and admin policies * Migrate to 2 system groups * Add system groups * Add system groups admin & read only * Dont' mutate parameters * Fix types --- homeassistant/auth/__init__.py | 6 +- homeassistant/auth/auth_store.py | 142 ++++++++++++++---- homeassistant/auth/const.py | 3 + homeassistant/auth/models.py | 1 + homeassistant/auth/permissions/__init__.py | 11 +- homeassistant/auth/permissions/const.py | 7 + homeassistant/auth/permissions/entities.py | 8 +- homeassistant/auth/permissions/merge.py | 2 +- .../auth/permissions/system_policies.py | 14 ++ .../auth/permissions/{common.py => types.py} | 2 - tests/auth/permissions/test_init.py | 12 -- .../auth/permissions/test_system_policies.py | 25 +++ tests/auth/test_auth_store.py | 141 ++++++++++++++++- tests/common.py | 3 +- 14 files changed, 309 insertions(+), 68 deletions(-) create mode 100644 homeassistant/auth/permissions/const.py create mode 100644 homeassistant/auth/permissions/system_policies.py rename homeassistant/auth/permissions/{common.py => types.py} (97%) create mode 100644 tests/auth/permissions/test_system_policies.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 9fd9bf3fa50..0011c98ce73 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -13,6 +13,7 @@ from homeassistant.core import callback, HomeAssistant from homeassistant.util import dt as dt_util from . import auth_store, models +from .const import GROUP_ID_ADMIN from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule from .providers import auth_provider_from_config, AuthProvider, LoginFlow @@ -133,7 +134,7 @@ class AuthManager: name=name, system_generated=True, is_active=True, - groups=[], + group_ids=[], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { @@ -144,11 +145,10 @@ class AuthManager: async def async_create_user(self, name: str) -> models.User: """Create a user.""" - group = (await self._store.async_get_groups())[0] kwargs = { 'name': name, 'is_active': True, - 'groups': [group] + 'group_ids': [GROUP_ID_ADMIN] } # type: Dict[str, Any] if await self._user_should_be_owner(): diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 8c328bfe13e..ab233489db0 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -10,11 +10,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util from . import models -from .permissions import DEFAULT_POLICY +from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from .permissions import system_policies +from .permissions.types import PolicyType # noqa: F401 STORAGE_VERSION = 1 STORAGE_KEY = 'auth' -INITIAL_GROUP_NAME = 'All Access' +GROUP_NAME_ADMIN = 'Administrators' +GROUP_NAME_READ_ONLY = 'Read Only' class AuthStore: @@ -63,7 +66,7 @@ class AuthStore: is_active: Optional[bool] = None, system_generated: Optional[bool] = None, credentials: Optional[models.Credentials] = None, - groups: Optional[List[models.Group]] = None) -> models.User: + group_ids: Optional[List[str]] = None) -> models.User: """Create a new user.""" if self._users is None: await self._async_load() @@ -71,11 +74,18 @@ class AuthStore: assert self._users is not None assert self._groups is not None + groups = [] + for group_id in (group_ids or []): + group = self._groups.get(group_id) + if group is None: + raise ValueError('Invalid group specified {}'.format(group_id)) + groups.append(group) + kwargs = { 'name': name, # Until we get group management, we just put everyone in the # same group. - 'groups': groups or [], + 'groups': groups, } # type: Dict[str, Any] if is_owner is not None: @@ -238,38 +248,98 @@ class AuthStore: users = OrderedDict() # type: Dict[str, models.User] groups = OrderedDict() # type: Dict[str, models.Group] - # When creating objects we mention each attribute explicetely. This + # Soft-migrating data as we load. We are going to make sure we have a + # read only group and an admin group. There are two states that we can + # migrate from: + # 1. Data from a recent version which has a single group without policy + # 2. Data from old version which has no groups + has_admin_group = False + has_read_only_group = False + group_without_policy = None + + # When creating objects we mention each attribute explicitly. This # prevents crashing if user rolls back HA version after a new property # was added. for group_dict in data.get('groups', []): + policy = None # type: Optional[PolicyType] + + if group_dict['id'] == GROUP_ID_ADMIN: + has_admin_group = True + + name = GROUP_NAME_ADMIN + policy = system_policies.ADMIN_POLICY + system_generated = True + + elif group_dict['id'] == GROUP_ID_READ_ONLY: + has_read_only_group = True + + name = GROUP_NAME_READ_ONLY + policy = system_policies.READ_ONLY_POLICY + system_generated = True + + else: + name = group_dict['name'] + policy = group_dict.get('policy') + system_generated = False + + # We don't want groups without a policy that are not system groups + # This is part of migrating from state 1 + if policy is None: + group_without_policy = group_dict['id'] + continue + groups[group_dict['id']] = models.Group( - name=group_dict['name'], id=group_dict['id'], - policy=group_dict.get('policy', DEFAULT_POLICY), + name=name, + policy=policy, + system_generated=system_generated, ) - migrate_group = None + # If there are no groups, add all existing users to the admin group. + # This is part of migrating from state 2 + migrate_users_to_admin_group = (not groups and + group_without_policy is None) - if not groups: - migrate_group = models.Group( - name=INITIAL_GROUP_NAME, - policy=DEFAULT_POLICY - ) - groups[migrate_group.id] = migrate_group + # If we find a no_policy_group, we need to migrate all users to the + # admin group. We only do this if there are no other groups, as is + # the expected state. If not expected state, not marking people admin. + # This is part of migrating from state 1 + if groups and group_without_policy is not None: + group_without_policy = None + + # This is part of migrating from state 1 and 2 + if not has_admin_group: + admin_group = _system_admin_group() + groups[admin_group.id] = admin_group + + # This is part of migrating from state 1 and 2 + if not has_read_only_group: + read_only_group = _system_read_only_group() + groups[read_only_group.id] = read_only_group for user_dict in data['users']: + # Collect the users group. + user_groups = [] + for group_id in user_dict.get('group_ids', []): + # This is part of migrating from state 1 + if group_id == group_without_policy: + group_id = GROUP_ID_ADMIN + user_groups.append(groups[group_id]) + + # This is part of migrating from state 2 + if (not user_dict['system_generated'] and + migrate_users_to_admin_group): + user_groups.append(groups[GROUP_ID_ADMIN]) + users[user_dict['id']] = models.User( name=user_dict['name'], - groups=[groups[group_id] for group_id - in user_dict.get('group_ids', [])], + groups=user_groups, id=user_dict['id'], is_owner=user_dict['is_owner'], is_active=user_dict['is_active'], system_generated=user_dict['system_generated'], ) - if migrate_group is not None and not user_dict['system_generated']: - users[user_dict['id']].groups = [migrate_group] for cred_dict in data['credentials']: users[cred_dict['user_id']].credentials.append(models.Credentials( @@ -356,11 +426,11 @@ class AuthStore: groups = [] for group in self._groups.values(): g_dict = { - 'name': group.name, 'id': group.id, } # type: Dict[str, Any] - if group.policy is not DEFAULT_POLICY: + if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN): + g_dict['name'] = group.name g_dict['policy'] = group.policy groups.append(g_dict) @@ -410,13 +480,29 @@ class AuthStore: """Set default values for auth store.""" self._users = OrderedDict() # type: Dict[str, models.User] - # Add default group - all_access_group = models.Group( - name=INITIAL_GROUP_NAME, - policy=DEFAULT_POLICY, - ) - groups = OrderedDict() # type: Dict[str, models.Group] - groups[all_access_group.id] = all_access_group - + admin_group = _system_admin_group() + groups[admin_group.id] = admin_group + read_only_group = _system_read_only_group() + groups[read_only_group.id] = read_only_group self._groups = groups + + +def _system_admin_group() -> models.Group: + """Create system admin group.""" + return models.Group( + name=GROUP_NAME_ADMIN, + id=GROUP_ID_ADMIN, + policy=system_policies.ADMIN_POLICY, + system_generated=True, + ) + + +def _system_read_only_group() -> models.Group: + """Create read only group.""" + return models.Group( + name=GROUP_NAME_READ_ONLY, + id=GROUP_ID_READ_ONLY, + policy=system_policies.READ_ONLY_POLICY, + system_generated=True, + ) diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index 2e57986958c..519669ead85 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -3,3 +3,6 @@ from datetime import timedelta ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) MFA_SESSION_EXPIRATION = timedelta(minutes=5) + +GROUP_ID_ADMIN = 'system-admin' +GROUP_ID_READ_ONLY = 'system-read-only' diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index fc35f1398db..cefaabe7521 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -22,6 +22,7 @@ class Group: name = attr.ib(type=str) # type: Optional[str] policy = attr.ib(type=perm_mdl.PolicyType) id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + system_generated = attr.ib(type=bool, default=False) @attr.s(slots=True) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index ee0d3af0c54..fd3cf81f029 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -7,18 +7,11 @@ import voluptuous as vol from homeassistant.core import State -from .common import CategoryType, PolicyType +from .const import CAT_ENTITIES +from .types import CategoryType, PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa - -# Default policy if group has no policy applied. -DEFAULT_POLICY = { - "entities": True -} # type: PolicyType - -CAT_ENTITIES = 'entities' - POLICY_SCHEMA = vol.Schema({ vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA }) diff --git a/homeassistant/auth/permissions/const.py b/homeassistant/auth/permissions/const.py new file mode 100644 index 00000000000..e60879881c1 --- /dev/null +++ b/homeassistant/auth/permissions/const.py @@ -0,0 +1,7 @@ +"""Permission constants.""" +CAT_ENTITIES = 'entities' +SUBCAT_ALL = 'all' + +POLICY_READ = 'read' +POLICY_CONTROL = 'control' +POLICY_EDIT = 'edit' diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index b38600fe130..89b9398628c 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -5,12 +5,8 @@ from typing import ( # noqa: F401 import voluptuous as vol -from .common import CategoryType, ValueType, SUBCAT_ALL - - -POLICY_READ = 'read' -POLICY_CONTROL = 'control' -POLICY_EDIT = 'edit' +from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT +from .types import CategoryType, ValueType SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(POLICY_READ): True, diff --git a/homeassistant/auth/permissions/merge.py b/homeassistant/auth/permissions/merge.py index 32cbfefcf1c..ec6375a0e3d 100644 --- a/homeassistant/auth/permissions/merge.py +++ b/homeassistant/auth/permissions/merge.py @@ -2,7 +2,7 @@ from typing import ( # noqa: F401 cast, Dict, List, Set) -from .common import PolicyType, CategoryType +from .types import PolicyType, CategoryType def merge_policies(policies: List[PolicyType]) -> PolicyType: diff --git a/homeassistant/auth/permissions/system_policies.py b/homeassistant/auth/permissions/system_policies.py new file mode 100644 index 00000000000..78da68c0d11 --- /dev/null +++ b/homeassistant/auth/permissions/system_policies.py @@ -0,0 +1,14 @@ +"""System policies.""" +from .const import CAT_ENTITIES, SUBCAT_ALL, POLICY_READ + +ADMIN_POLICY = { + CAT_ENTITIES: True, +} + +READ_ONLY_POLICY = { + CAT_ENTITIES: { + SUBCAT_ALL: { + POLICY_READ: True + } + } +} diff --git a/homeassistant/auth/permissions/common.py b/homeassistant/auth/permissions/types.py similarity index 97% rename from homeassistant/auth/permissions/common.py rename to homeassistant/auth/permissions/types.py index f87f9d70ddf..1871861f291 100644 --- a/homeassistant/auth/permissions/common.py +++ b/homeassistant/auth/permissions/types.py @@ -29,5 +29,3 @@ CategoryType = Union[ # Example: { entities: … } PolicyType = Mapping[str, CategoryType] - -SUBCAT_ALL = 'all' diff --git a/tests/auth/permissions/test_init.py b/tests/auth/permissions/test_init.py index 60ec3cb4314..fdc5440a9d5 100644 --- a/tests/auth/permissions/test_init.py +++ b/tests/auth/permissions/test_init.py @@ -32,15 +32,3 @@ def test_owner_permissions(): State('light.balcony', 'on'), ] assert permissions.OwnerPermissions.filter_states(states) == states - - -def test_default_policy_allow_all(): - """Test that the default policy is to allow all entity actions.""" - perm = permissions.PolicyPermissions(permissions.DEFAULT_POLICY) - assert perm.check_entity('light.kitchen', 'read') - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - assert perm.filter_states(states) == states diff --git a/tests/auth/permissions/test_system_policies.py b/tests/auth/permissions/test_system_policies.py new file mode 100644 index 00000000000..ba6fe214146 --- /dev/null +++ b/tests/auth/permissions/test_system_policies.py @@ -0,0 +1,25 @@ +"""Test system policies.""" +from homeassistant.auth.permissions import ( + PolicyPermissions, system_policies, POLICY_SCHEMA) + + +def test_admin_policy(): + """Test admin policy works.""" + # Make sure it's valid + POLICY_SCHEMA(system_policies.ADMIN_POLICY) + + perms = PolicyPermissions(system_policies.ADMIN_POLICY) + assert perms.check_entity('light.kitchen', 'read') + assert perms.check_entity('light.kitchen', 'control') + assert perms.check_entity('light.kitchen', 'edit') + + +def test_read_only_policy(): + """Test read only policy works.""" + # Make sure it's valid + POLICY_SCHEMA(system_policies.READ_ONLY_POLICY) + + perms = PolicyPermissions(system_policies.READ_ONLY_POLICY) + assert perms.check_entity('light.kitchen', 'read') + assert not perms.check_entity('light.kitchen', 'control') + assert not perms.check_entity('light.kitchen', 'edit') diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index a3bdbab93d7..b76d68fbeac 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -2,8 +2,8 @@ from homeassistant.auth import auth_store -async def test_loading_old_data_format(hass, hass_storage): - """Test we correctly load an old data format.""" +async def test_loading_no_group_data_format(hass, hass_storage): + """Test we correctly load old data without any groups.""" hass_storage[auth_store.STORAGE_KEY] = { 'version': 1, 'data': { @@ -60,9 +60,15 @@ async def test_loading_old_data_format(hass, hass_storage): store = auth_store.AuthStore(hass) groups = await store.async_get_groups() - assert len(groups) == 1 - group = groups[0] - assert group.name == "All Access" + assert len(groups) == 2 + admin_group = groups[0] + assert admin_group.name == auth_store.GROUP_NAME_ADMIN + assert admin_group.system_generated + assert admin_group.id == auth_store.GROUP_ID_ADMIN + read_group = groups[1] + assert read_group.name == auth_store.GROUP_NAME_READ_ONLY + assert read_group.system_generated + assert read_group.id == auth_store.GROUP_ID_READ_ONLY users = await store.async_get_users() assert len(users) == 2 @@ -70,7 +76,7 @@ async def test_loading_old_data_format(hass, hass_storage): owner, system = users assert owner.system_generated is False - assert owner.groups == [group] + assert owner.groups == [admin_group] assert len(owner.refresh_tokens) == 1 owner_token = list(owner.refresh_tokens.values())[0] assert owner_token.id == 'user-token-id' @@ -80,3 +86,126 @@ async def test_loading_old_data_format(hass, hass_storage): assert len(system.refresh_tokens) == 1 system_token = list(system.refresh_tokens.values())[0] assert system_token.id == 'system-token-id' + + +async def test_loading_all_access_group_data_format(hass, hass_storage): + """Test we correctly load old data with single group.""" + hass_storage[auth_store.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'credentials': [], + 'users': [ + { + "id": "user-id", + "is_active": True, + "is_owner": True, + "name": "Paulus", + "system_generated": False, + 'group_ids': ['abcd-all-access'] + }, + { + "id": "system-id", + "is_active": True, + "is_owner": True, + "name": "Hass.io", + "system_generated": True, + } + ], + "groups": [ + { + "id": "abcd-all-access", + "name": "All Access", + } + ], + "refresh_tokens": [ + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id", + "jwt_key": "some-key", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "user-id" + }, + { + "access_token_expiration": 1800.0, + "client_id": None, + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "system-token-id", + "jwt_key": "some-key", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "system-id" + }, + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "hidden-because-no-jwt-id", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "user-id" + }, + ] + } + } + + store = auth_store.AuthStore(hass) + groups = await store.async_get_groups() + assert len(groups) == 2 + admin_group = groups[0] + assert admin_group.name == auth_store.GROUP_NAME_ADMIN + assert admin_group.system_generated + assert admin_group.id == auth_store.GROUP_ID_ADMIN + read_group = groups[1] + assert read_group.name == auth_store.GROUP_NAME_READ_ONLY + assert read_group.system_generated + assert read_group.id == auth_store.GROUP_ID_READ_ONLY + + users = await store.async_get_users() + assert len(users) == 2 + + owner, system = users + + assert owner.system_generated is False + assert owner.groups == [admin_group] + assert len(owner.refresh_tokens) == 1 + owner_token = list(owner.refresh_tokens.values())[0] + assert owner_token.id == 'user-token-id' + + assert system.system_generated is True + assert system.groups == [] + assert len(system.refresh_tokens) == 1 + system_token = list(system.refresh_tokens.values())[0] + assert system_token.id == 'system-token-id' + + +async def test_loading_empty_data(hass, hass_storage): + """Test we correctly load with no existing data.""" + store = auth_store.AuthStore(hass) + groups = await store.async_get_groups() + assert len(groups) == 2 + admin_group = groups[0] + assert admin_group.name == auth_store.GROUP_NAME_ADMIN + assert admin_group.system_generated + assert admin_group.id == auth_store.GROUP_ID_ADMIN + read_group = groups[1] + assert read_group.name == auth_store.GROUP_NAME_READ_ONLY + assert read_group.system_generated + assert read_group.id == auth_store.GROUP_ID_READ_ONLY + + users = await store.async_get_users() + assert len(users) == 0 + + +async def test_system_groups_only_store_id(hass, hass_storage): + """Test that for system groups we only store the ID.""" + store = auth_store.AuthStore(hass) + await store._async_load() + data = store._data_to_save() + assert len(data['users']) == 0 + assert data['groups'] == [ + {'id': auth_store.GROUP_ID_ADMIN}, + {'id': auth_store.GROUP_ID_READ_ONLY}, + ] diff --git a/tests/common.py b/tests/common.py index 44f934e4cb3..b3d72cbebbf 100644 --- a/tests/common.py +++ b/tests/common.py @@ -15,6 +15,7 @@ from contextlib import contextmanager from homeassistant import auth, core as ha, config_entries from homeassistant.auth import ( models as auth_models, auth_store, providers as auth_providers) +from homeassistant.auth.permissions import system_policies from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -349,7 +350,7 @@ class MockGroup(auth_models.Group): """Mock a group in Home Assistant.""" def __init__(self, id=None, name='Mock Group', - policy=auth_store.DEFAULT_POLICY): + policy=system_policies.ADMIN_POLICY): """Mock a group.""" kwargs = { 'name': name, From 9472529d43ac0b3f60c307c42e007606003e690c Mon Sep 17 00:00:00 2001 From: Matthias Urlichs Date: Thu, 8 Nov 2018 12:59:58 +0100 Subject: [PATCH 099/238] Doc fix: a circular dependency does not raise an error. (#18298) This is easier to handle than raising an exception: a circular dependency causes multiple error entries in the log, which is what we want. This is harder to achieve with an exception. Since there is only one user of this code, I choose to fix the documentation -- instead of adding a lot of mostly-useless exception handling. Closes: #13147 --- homeassistant/loader.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6fb003926e1..61aacd3b233 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -209,8 +209,9 @@ def load_order_component(hass, # type: HomeAssistant comp_name: str) -> OrderedSet: """Return an OrderedSet of components in the correct order of loading. - Raises HomeAssistantError if a circular dependency is detected. - Returns an empty list if component could not be loaded. + Returns an empty list if a circular dependency is detected + or the component could not be loaded. In both cases, the error is + logged. Async friendly. """ From fd2987e551c466eaaa866f1e7eb911931ae9d7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 8 Nov 2018 16:37:11 +0100 Subject: [PATCH 100/238] Add new launch sensor to keep track of space launches. (#18274) * Add new launch sensor to keep track of space launches. * Added attribution to Launch Library. * Adds data class and throtle, reuse aiohttp session. * Add one extra blank line before the new class.. * Change throttle to simpler SCAN_INTERVAL. * Remove the usage of the LaunchData class. * Bump pylaunches, remove . from log, fix line breaker for agency_country_code, remove CONF_ from ATTRIBUTION. --- .coveragerc | 1 + homeassistant/components/sensor/launch.py | 92 +++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 96 insertions(+) create mode 100644 homeassistant/components/sensor/launch.py diff --git a/.coveragerc b/.coveragerc index 8d037f0c2e7..727e57149ba 100644 --- a/.coveragerc +++ b/.coveragerc @@ -728,6 +728,7 @@ omit = homeassistant/components/sensor/kwb.py homeassistant/components/sensor/lacrosse.py homeassistant/components/sensor/lastfm.py + homeassistant/components/sensor/launch.py homeassistant/components/sensor/linky.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py diff --git a/homeassistant/components/sensor/launch.py b/homeassistant/components/sensor/launch.py new file mode 100644 index 00000000000..ce883d94cd3 --- /dev/null +++ b/homeassistant/components/sensor/launch.py @@ -0,0 +1,92 @@ +""" +A sensor platform that give you information about the next space launch. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/sensor.launch/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['pylaunches==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by Launch Library." + +DEFAULT_NAME = 'Launch' + +SCAN_INTERVAL = timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + }) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Create the launch sensor.""" + from pylaunches.api import Launches + + name = config[CONF_NAME] + + session = async_get_clientsession(hass) + launches = Launches(hass.loop, session) + sensor = [LaunchSensor(launches, name)] + async_add_entities(sensor, True) + + +class LaunchSensor(Entity): + """Representation of a launch Sensor.""" + + def __init__(self, launches, name): + """Initialize the sensor.""" + self.launches = launches + self._attributes = {} + self._name = name + self._state = None + + async def async_update(self): + """Get the latest data.""" + await self.launches.get_launches() + if self.launches.launches is None: + _LOGGER.error("No data recieved") + return + try: + data = self.launches.launches[0] + self._state = data['name'] + self._attributes['launch_time'] = data['start'] + self._attributes['agency'] = data['agency'] + agency_country_code = data['agency_country_code'] + self._attributes['agency_country_code'] = agency_country_code + self._attributes['stream'] = data['stream'] + self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION + except (KeyError, IndexError) as error: + _LOGGER.debug("Error getting data, %s", error) + + @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): + """Return the icon of the sensor.""" + return 'mdi:rocket' + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + return self._attributes diff --git a/requirements_all.txt b/requirements_all.txt index 1f62dd3ef3f..e23e6e7ff77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -985,6 +985,9 @@ pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm pylast==2.4.0 +# homeassistant.components.sensor.launch +pylaunches==0.1.2 + # homeassistant.components.media_player.lg_netcast pylgnetcast-homeassistant==0.2.0.dev0 From 8f107c46fec56cb415a987f1d7d18edec9529fd5 Mon Sep 17 00:00:00 2001 From: horga83 Date: Thu, 8 Nov 2018 09:49:00 -0800 Subject: [PATCH 101/238] W800rf32 (#17920) * Initial commit of w800rf32 component and binary_sensor. The W800 family is an X10 RF receiver used with keypads and motion sensors etc. * Initial commit of w800rf32 switch platform. The W800 family is an X10 RF receiver used with keypads and motion sensors etc. * Remove unused code. * Additions for w800rf32 component and platform code * Fix w800rf32 minor lint issues and make sure gen_requirements.py correctlly adds w800rf32 * Added dispatch_connect/send and refactor somewhat * Fix missed indentation lint * Removed shared entity dict and use async_dispatch code * Fix long line not caught by lint * One more line too long, missed it * Remove unused code and changes for async * Remove @callback that shouldn't be there. * Remove switch platform, can't have read only switch. * Remove unused CONF_DEBUG * Remove used vars and make CONF_DEVICES required * Move CONF_OFF_DELAY to platform, only used there --- .coveragerc | 3 + .../components/binary_sensor/w800rf32.py | 132 ++++++++++++++++++ homeassistant/components/w800rf32.py | 67 +++++++++ requirements_all.txt | 3 + 4 files changed, 205 insertions(+) create mode 100644 homeassistant/components/binary_sensor/w800rf32.py create mode 100644 homeassistant/components/w800rf32.py diff --git a/.coveragerc b/.coveragerc index 727e57149ba..f8423c089bd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -371,6 +371,9 @@ omit = homeassistant/components/*/webostv.py + homeassistant/components/w800rf32.py + homeassistant/components/*/w800rf32.py + homeassistant/components/wemo.py homeassistant/components/*/wemo.py diff --git a/homeassistant/components/binary_sensor/w800rf32.py b/homeassistant/components/binary_sensor/w800rf32.py new file mode 100644 index 00000000000..48ac6f41a12 --- /dev/null +++ b/homeassistant/components/binary_sensor/w800rf32.py @@ -0,0 +1,132 @@ +""" +Support for w800rf32 binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.w800rf32/ + +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.components.w800rf32 import (W800RF32_DEVICE) +from homeassistant.const import (CONF_DEVICE_CLASS, CONF_NAME, CONF_DEVICES) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import event as evt +from homeassistant.util import dt as dt_util +from homeassistant.helpers.dispatcher import (async_dispatcher_connect) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['w800rf32'] +CONF_OFF_DELAY = 'off_delay' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICES): { + cv.string: vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OFF_DELAY): + vol.All(cv.time_period, cv.positive_timedelta) + }) + }, +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup_platform(hass, config, + add_entities, discovery_info=None): + """Set up the Binary Sensor platform to w800rf32.""" + binary_sensors = [] + # device_id --> "c1 or a3" X10 device. entity (type dictionary) + # --> name, device_class etc + for device_id, entity in config[CONF_DEVICES].items(): + + _LOGGER.debug("Add %s w800rf32.binary_sensor (class %s)", + entity[CONF_NAME], entity.get(CONF_DEVICE_CLASS)) + + device = W800rf32BinarySensor( + device_id, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS), + entity.get(CONF_OFF_DELAY)) + + binary_sensors.append(device) + + add_entities(binary_sensors) + + +class W800rf32BinarySensor(BinarySensorDevice): + """A representation of a w800rf32 binary sensor.""" + + def __init__(self, device_id, name, device_class=None, off_delay=None): + """Initialize the w800rf32 sensor.""" + self._signal = W800RF32_DEVICE.format(device_id) + self._name = name + self._device_class = device_class + self._off_delay = off_delay + self._state = False + self._delay_listener = None + + @callback + def _off_delay_listener(self, now): + """Switch device off after a delay.""" + self._delay_listener = None + self.update_state(False) + + @property + def name(self): + """Return the device name.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class.""" + return self._device_class + + @property + def is_on(self): + """Return true if the sensor state is True.""" + return self._state + + @callback + def binary_sensor_update(self, event): + """Call for control updates from the w800rf32 gateway.""" + import W800rf32 as w800rf32mod + + if not isinstance(event, w800rf32mod.W800rf32Event): + return + + dev_id = event.device + command = event.command + + _LOGGER.debug( + "BinarySensor update (Device ID: %s Command %s ...)", + dev_id, command) + + # Update the w800rf32 device state + if command in ('On', 'Off'): + is_on = command == 'On' + self.update_state(is_on) + + if (self.is_on and self._off_delay is not None and + self._delay_listener is None): + + self._delay_listener = evt.async_track_point_in_time( + self.hass, self._off_delay_listener, + dt_util.utcnow() + self._off_delay) + + def update_state(self, state): + """Update the state of the device.""" + self._state = state + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + async_dispatcher_connect(self.hass, self._signal, + self.binary_sensor_update) diff --git a/homeassistant/components/w800rf32.py b/homeassistant/components/w800rf32.py new file mode 100644 index 00000000000..4b237272546 --- /dev/null +++ b/homeassistant/components/w800rf32.py @@ -0,0 +1,67 @@ +""" +Support for w800rf32 components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/w800rf32/ + +""" +import logging + +import voluptuous as vol + +from homeassistant.const import (CONF_DEVICE, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import (dispatcher_send) + +REQUIREMENTS = ['pyW800rf32==0.1'] + +DOMAIN = 'w800rf32' +DATA_W800RF32 = 'data_w800rf32' +W800RF32_DEVICE = 'w800rf32_{}' + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the w800rf32 component.""" + # Try to load the W800rf32 module. + import W800rf32 as w800 + + # Declare the Handle event + def handle_receive(event): + """Handle received messages from w800rf32 gateway.""" + # Log event + if not event.device: + return + _LOGGER.debug("Receive W800rf32 event in handle_receive") + + # Get device_type from device_id in hass.data + device_id = event.device.lower() + signal = W800RF32_DEVICE.format(device_id) + dispatcher_send(hass, signal, event) + + # device --> /dev/ttyUSB0 + device = config[DOMAIN][CONF_DEVICE] + w800_object = w800.Connect(device, None) + + def _start_w800rf32(event): + w800_object.event_callback = handle_receive + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_w800rf32) + + def _shutdown_w800rf32(event): + """Close connection with w800rf32.""" + w800_object.close_connection() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_w800rf32) + + hass.data[DATA_W800RF32] = w800_object + + return True diff --git a/requirements_all.txt b/requirements_all.txt index e23e6e7ff77..e54612b7d40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -817,6 +817,9 @@ pyTibber==0.7.4 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.w800rf32 +pyW800rf32==0.1 + # homeassistant.components.sensor.noaa_tides # py_noaa==0.3.0 From 05eac915d16fa5108a6a5892d5f94c808a597f91 Mon Sep 17 00:00:00 2001 From: Brig Lamoreaux Date: Thu, 8 Nov 2018 11:19:30 -0700 Subject: [PATCH 102/238] Srpenergy (#18036) * Add srp_energy * Update message on TypeError. Add check for None state. * Add check for none in history * Add srpenergy to Test requirements. * Add srpenergy to requirments. * Change = to ==. * Change import for srpenergy * Fix Flak8 errors * Add srp to gen requirements script * Change config name. * Add daily usage test * Add test for daily usage. * Fix Flake8 message. * Remove blank after docstring. * Add srpenergy to coverage * Bump requires version to srpenergy * Fix type in coverage. Import from Sensor. Use dict. * Update to 1.0.5. Check credentials on setup. Standalone test. * Fix formating. * Remove period. Rename _ variables. * Fix rebase merge * Add rebase requirement * Improve Mock Patching. --- .coveragerc | 1 + homeassistant/components/sensor/srp_energy.py | 149 ++++++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_srp_energy.py | 62 ++++++++ 6 files changed, 219 insertions(+) create mode 100644 homeassistant/components/sensor/srp_energy.py create mode 100644 tests/components/sensor/test_srp_energy.py diff --git a/.coveragerc b/.coveragerc index f8423c089bd..0346c28695d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -800,6 +800,7 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/syncthru.py homeassistant/components/sensor/synologydsm.py + homeassistant/components/sensor/srp_energy.py homeassistant/components/sensor/systemmonitor.py homeassistant/components/sensor/sytadin.py homeassistant/components/sensor/tank_utility.py diff --git a/homeassistant/components/sensor/srp_energy.py b/homeassistant/components/sensor/srp_energy.py new file mode 100644 index 00000000000..8e1de24a2c5 --- /dev/null +++ b/homeassistant/components/sensor/srp_energy.py @@ -0,0 +1,149 @@ +""" +Platform for retrieving energy data from SRP. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/sensor.srp_energy/ +""" +from datetime import datetime, timedelta +import logging + +from requests.exceptions import ( + ConnectionError as ConnectError, HTTPError, Timeout) +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, + CONF_USERNAME, CONF_ID) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['srpenergy==1.0.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Powered by SRP Energy" + +DEFAULT_NAME = 'SRP Energy' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) +ENERGY_KWH = 'kWh' + +ATTR_READING_COST = "reading_cost" +ATTR_READING_TIME = 'datetime' +ATTR_READING_USAGE = 'reading_usage' +ATTR_DAILY_USAGE = 'daily_usage' +ATTR_USAGE_HISTORY = 'usage_history' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the SRP energy.""" + name = config[CONF_NAME] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + account_id = config[CONF_ID] + + from srpenergy.client import SrpEnergyClient + + srp_client = SrpEnergyClient(account_id, username, password) + + if not srp_client.validate(): + _LOGGER.error("Couldn't connect to %s. Check credentials", name) + return + + add_entities([SrpEnergy(name, srp_client)], True) + + +class SrpEnergy(Entity): + """Representation of an srp usage.""" + + def __init__(self, name, client): + """Initialize SRP Usage.""" + self._state = None + self._name = name + self._client = client + self._history = None + self._usage = None + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def state(self): + """Return the current state.""" + if self._state is None: + return None + + return "{0:.2f}".format(self._state) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return ENERGY_KWH + + @property + def history(self): + """Return the energy usage history of this entity, if any.""" + if self._usage is None: + return None + + history = [{ + ATTR_READING_TIME: isodate, + ATTR_READING_USAGE: kwh, + ATTR_READING_COST: cost + } for _, _, isodate, kwh, cost in self._usage] + + return history + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + ATTR_USAGE_HISTORY: self.history + } + + return attributes + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest usage from SRP Energy.""" + start_date = datetime.now() + timedelta(days=-1) + end_date = datetime.now() + + try: + + usage = self._client.usage(start_date, end_date) + + daily_usage = 0.0 + for _, _, _, kwh, _ in usage: + daily_usage += float(kwh) + + if usage: + + self._state = daily_usage + self._usage = usage + + else: + _LOGGER.error("Unable to fetch data from SRP. No data") + + except (ConnectError, HTTPError, Timeout) as error: + _LOGGER.error("Unable to connect to SRP. %s", error) + except ValueError as error: + _LOGGER.error("Value error connecting to SRP. %s", error) + except TypeError as error: + _LOGGER.error("Type error connecting to SRP. " + "Check username and password. %s", error) diff --git a/requirements_all.txt b/requirements_all.txt index e54612b7d40..d8a41643e60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1447,6 +1447,9 @@ spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.sensor.sql sqlalchemy==1.2.13 +# homeassistant.components.sensor.srp_energy +srpenergy==1.0.5 + # homeassistant.components.sensor.starlingbank starlingbank==1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2a64592498..3a6470fa474 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -238,6 +238,9 @@ somecomfort==0.5.2 # homeassistant.components.sensor.sql sqlalchemy==1.2.13 +# homeassistant.components.sensor.srp_energy +srpenergy==1.0.5 + # homeassistant.components.statsd statsd==3.2.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e5da8b48360..698b35e776f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -104,6 +104,7 @@ TEST_REQUIREMENTS = ( 'smhi-pkg', 'somecomfort', 'sqlalchemy', + 'srpenergy', 'statsd', 'uvcclient', 'warrant', diff --git a/tests/components/sensor/test_srp_energy.py b/tests/components/sensor/test_srp_energy.py new file mode 100644 index 00000000000..8b92e9e9467 --- /dev/null +++ b/tests/components/sensor/test_srp_energy.py @@ -0,0 +1,62 @@ +"""The tests for the Srp Energy Platform.""" +from unittest.mock import patch +import logging +from homeassistant.setup import async_setup_component + +_LOGGER = logging.getLogger(__name__) + +VALID_CONFIG_MINIMAL = { + 'sensor': { + 'platform': 'srp_energy', + 'username': 'foo', + 'password': 'bar', + 'id': 1234 + } +} + +PATCH_INIT = 'srpenergy.client.SrpEnergyClient.__init__' +PATCH_VALIDATE = 'srpenergy.client.SrpEnergyClient.validate' +PATCH_USAGE = 'srpenergy.client.SrpEnergyClient.usage' + + +def mock_usage(self, startdate, enddate): # pylint: disable=invalid-name + """Mock srpusage usage.""" + _LOGGER.log(logging.INFO, "Calling mock usage") + usage = [ + ('9/19/2018', '12:00 AM', '2018-09-19T00:00:00-7:00', '1.2', '0.17'), + ('9/19/2018', '1:00 AM', '2018-09-19T01:00:00-7:00', '2.1', '0.30'), + ('9/19/2018', '2:00 AM', '2018-09-19T02:00:00-7:00', '1.5', '0.23'), + ('9/19/2018', '9:00 PM', '2018-09-19T21:00:00-7:00', '1.2', '0.19'), + ('9/19/2018', '10:00 PM', '2018-09-19T22:00:00-7:00', '1.1', '0.18'), + ('9/19/2018', '11:00 PM', '2018-09-19T23:00:00-7:00', '0.4', '0.09') + ] + return usage + + +async def test_setup_with_config(hass): + """Test the platform setup with configuration.""" + with patch(PATCH_INIT, return_value=None), \ + patch(PATCH_VALIDATE, return_value=True), \ + patch(PATCH_USAGE, new=mock_usage): + + await async_setup_component(hass, 'sensor', VALID_CONFIG_MINIMAL) + + state = hass.states.get('sensor.srp_energy') + assert state is not None + + +async def test_daily_usage(hass): + """Test the platform daily usage.""" + with patch(PATCH_INIT, return_value=None), \ + patch(PATCH_VALIDATE, return_value=True), \ + patch(PATCH_USAGE, new=mock_usage): + + await async_setup_component(hass, 'sensor', VALID_CONFIG_MINIMAL) + + state = hass.states.get('sensor.srp_energy') + + assert state + assert state.state == '7.50' + + assert state.attributes + assert state.attributes.get('unit_of_measurement') From ae85baf3967c610ef6350ac6c8b997316fa13403 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Thu, 8 Nov 2018 16:08:36 -0700 Subject: [PATCH 103/238] Restrict recorder query to include max age (#18231) * Update query to include maxAge Updated the query from recorded to include MaxAge if set; reducing the amount of records retrieved that would otherwise be purged anyway for the sensor. * Added newline in docstring Added newline in docstring. * Add test + small fix Added test to ensure query works correctly Query should be greater then or equal instead of greater then. Fixed. * Fixed lint issue Fixed lint issue. --- homeassistant/components/sensor/statistics.py | 15 ++++- tests/components/sensor/test_statistics.py | 59 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 6181a4ae094..26253abd484 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -247,6 +247,9 @@ class StatisticsSensor(Entity): The query will get the list of states in DESCENDING order so that we can limit the result to self._sample_size. Afterwards reverse the list so that we get it in the right order again. + + If MaxAge is provided then query will restrict to entries younger then + current datetime - MaxAge. """ from homeassistant.components.recorder.models import States _LOGGER.debug("%s: initializing values from the database", @@ -254,7 +257,17 @@ class StatisticsSensor(Entity): with session_scope(hass=self._hass) as session: query = session.query(States)\ - .filter(States.entity_id == self._entity_id.lower())\ + .filter(States.entity_id == self._entity_id.lower()) + + if self._max_age is not None: + records_older_then = dt_util.utcnow() - self._max_age + _LOGGER.debug("%s: retrieve records not older then %s", + self.entity_id, records_older_then) + query = query.filter(States.last_updated >= records_older_then) + else: + _LOGGER.debug("%s: retrieving all records.", self.entity_id) + + query = query\ .order_by(States.last_updated.desc())\ .limit(self._sampling_size) states = execute(query) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 0bf9ecd8c6f..9188513b861 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -3,6 +3,7 @@ import unittest import statistics from homeassistant.setup import setup_component +from homeassistant.components.sensor.statistics import StatisticsSensor from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN) from homeassistant.util import dt as dt_util @@ -233,3 +234,61 @@ class TestStatisticsSensor(unittest.TestCase): # check if the result is as in test_sensor_source() state = self.hass.states.get('sensor.test_mean') assert str(self.mean) == state.state + + def test_initialize_from_database_with_maxage(self): + """Test initializing the statistics from the database.""" + mock_data = { + 'return_time': datetime(2017, 8, 2, 12, 23, 42, + tzinfo=dt_util.UTC), + } + + def mock_now(): + return mock_data['return_time'] + + # Testing correct retrieval from recorder, thus we do not + # want purging to occur within the class itself. + def mock_purge(self): + return + + # Set maximum age to 3 hours. + max_age = 3 + # Determine what our minimum age should be based on test values. + expected_min_age = mock_data['return_time'] + \ + timedelta(hours=len(self.values) - max_age) + + # enable the recorder + init_recorder_component(self.hass) + + with patch('homeassistant.components.sensor.statistics.dt_util.utcnow', + new=mock_now), \ + patch.object(StatisticsSensor, '_purge_old', mock_purge): + # store some values + for value in self.values: + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + # insert the next value 1 hour later + mock_data['return_time'] += timedelta(hours=1) + + # wait for the recorder to really store the data + self.hass.data[recorder.DATA_INSTANCE].block_till_done() + # only now create the statistics component, so that it must read + # the data from the database + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'sampling_size': 100, + 'max_age': {'hours': max_age} + } + }) + + # check if the result is as in test_sensor_source() + state = self.hass.states.get('sensor.test_mean') + + assert expected_min_age == state.attributes.get('min_age') + # The max_age timestamp should be 1 hour before what we have right + # now in mock_data['return_time']. + assert mock_data['return_time'] == state.attributes.get('max_age') +\ + timedelta(hours=1) From b803075eb41009e81c5fc8d2889dfd224f3debc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 9 Nov 2018 09:41:08 +0000 Subject: [PATCH 104/238] fix last device ignored (#18329) --- homeassistant/components/device_tracker/huawei_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/huawei_router.py b/homeassistant/components/device_tracker/huawei_router.py index f5e4fa8a714..18f3c0b8c62 100644 --- a/homeassistant/components/device_tracker/huawei_router.py +++ b/homeassistant/components/device_tracker/huawei_router.py @@ -39,7 +39,7 @@ Device = namedtuple('Device', ['name', 'ip', 'mac', 'state']) class HuaweiDeviceScanner(DeviceScanner): """This class queries a router running HUAWEI firmware.""" - ARRAY_REGEX = re.compile(r'var UserDevinfo = new Array\((.*),null\);') + ARRAY_REGEX = re.compile(r'var UserDevinfo = new Array\((.*)null\);') DEVICE_REGEX = re.compile(r'new USERDevice\((.*?)\),') DEVICE_ATTR_REGEX = re.compile( '"(?P.*?)","(?P.*?)",' From c5716371765b982c5ebc66ebaddf1f0a704f7576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 9 Nov 2018 11:36:00 +0100 Subject: [PATCH 105/238] Fix xiaomi binary_sensor warning (#18280) * Fix xiaomi binary_sensor warning * Fix xiaomi binary_sensor warning --- homeassistant/components/binary_sensor/xiaomi_aqara.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index e082c886f03..45217c42c1d 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -357,6 +357,9 @@ class XiaomiVibration(XiaomiBinarySensor): def parse_data(self, data, raw_data): """Parse data sent by gateway.""" value = data.get(self._data_key) + if value is None: + return False + if value not in ('vibrate', 'tilt', 'free_fall'): _LOGGER.warning("Unsupported movement_type detected: %s", value) From f97130911362b07e17feaa920d9e5af719668f13 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 9 Nov 2018 08:23:07 -0700 Subject: [PATCH 106/238] Add support for sensors from Flu Near You (#18136) * Add support for sensors from Flu Near You * Added sensor for total reports with symptoms * Member comments * Member comments --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/sensor/flunearyou.py | 213 ++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 218 insertions(+) create mode 100644 homeassistant/components/sensor/flunearyou.py diff --git a/.coveragerc b/.coveragerc index 0346c28695d..189785e906e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -707,6 +707,7 @@ omit = homeassistant/components/sensor/fints.py homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py + homeassistant/components/sensor/flunearyou.py homeassistant/components/sensor/folder.py homeassistant/components/sensor/foobot.py homeassistant/components/sensor/fritzbox_callmonitor.py diff --git a/CODEOWNERS b/CODEOWNERS index 9fc93b958e8..dabc3bbd4db 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -102,6 +102,7 @@ homeassistant/components/sensor/darksky.py @fabaff homeassistant/components/sensor/file.py @fabaff homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/fixer.py @fabaff +homeassistant/components/sensor/flunearyou.py.py @bachya homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/gitter.py @fabaff homeassistant/components/sensor/glances.py @fabaff diff --git a/homeassistant/components/sensor/flunearyou.py b/homeassistant/components/sensor/flunearyou.py new file mode 100644 index 00000000000..2c3598044bd --- /dev/null +++ b/homeassistant/components/sensor/flunearyou.py @@ -0,0 +1,213 @@ +""" +Support for user- and CDC-based flu info sensors from Flu Near You. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.flunearyou/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_STATE, CONF_LATITUDE, CONF_MONITORED_CONDITIONS, + CONF_LONGITUDE) +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['pyflunearyou==0.0.2'] +_LOGGER = logging.getLogger(__name__) + +ATTR_CITY = 'city' +ATTR_REPORTED_DATE = 'reported_date' +ATTR_REPORTED_LATITUDE = 'reported_latitude' +ATTR_REPORTED_LONGITUDE = 'reported_longitude' +ATTR_ZIP_CODE = 'zip_code' + +DEFAULT_ATTRIBUTION = 'Data provided by Flu Near You' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +SCAN_INTERVAL = timedelta(minutes=30) + +CATEGORY_CDC_REPORT = 'cdc_report' +CATEGORY_USER_REPORT = 'user_report' + +TYPE_CDC_LEVEL = 'level' +TYPE_CDC_LEVEL2 = 'level2' +TYPE_USER_CHICK = 'chick' +TYPE_USER_DENGUE = 'dengue' +TYPE_USER_FLU = 'flu' +TYPE_USER_LEPTO = 'lepto' +TYPE_USER_NO_NONE = 'none' +TYPE_USER_SYMPTOMS = 'symptoms' +TYPE_USER_TOTAL = 'total' + +SENSORS = { + CATEGORY_CDC_REPORT: [ + (TYPE_CDC_LEVEL, 'CDC Level', 'mdi:biohazard', None), + (TYPE_CDC_LEVEL2, 'CDC Level 2', 'mdi:biohazard', None), + ], + CATEGORY_USER_REPORT: [ + (TYPE_USER_CHICK, 'Avian Flu Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_DENGUE, 'Dengue Fever Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_FLU, 'Flu Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_LEPTO, 'Leptospirosis Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_NO_NONE, 'No Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_SYMPTOMS, 'Flu-like Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_TOTAL, 'Total Symptoms', 'mdi:alert', 'reports'), + ] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Configure the platform and add the sensors.""" + from pyflunearyou import create_client + from pyflunearyou.errors import FluNearYouError + + websession = aiohttp_client.async_get_clientsession(hass) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + identifier = '{0},{1}'.format(latitude, longitude) + + try: + client = await create_client(latitude, longitude, websession) + except FluNearYouError as err: + _LOGGER.error('There was an error while setting up: %s', err) + return + + fny = FluNearYouData(client, config[CONF_MONITORED_CONDITIONS]) + await fny.async_update() + + sensors = [ + FluNearYouSensor(fny, kind, name, identifier, category, icon, unit) + for category in config[CONF_MONITORED_CONDITIONS] + for kind, name, icon, unit in SENSORS[category] + ] + + async_add_entities(sensors, True) + + +class FluNearYouSensor(Entity): + """Define a base Flu Near You sensor.""" + + def __init__(self, fny, kind, name, identifier, category, icon, unit): + """Initialize the sensor.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._category = category + self._icon = icon + self._identifier = identifier + self._kind = kind + self._name = name + self._state = None + self._unit = unit + self.fny = fny + + @property + def available(self): + """Return True if entity is available.""" + return bool(self.fny.data[self._category]) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}'.format(self._identifier, self._kind) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + async def async_update(self): + """Update the sensor.""" + await self.fny.async_update() + + cdc_data = self.fny.data.get(CATEGORY_CDC_REPORT) + user_data = self.fny.data.get(CATEGORY_USER_REPORT) + + if self._category == CATEGORY_CDC_REPORT and cdc_data: + self._attrs.update({ + ATTR_REPORTED_DATE: cdc_data['week_date'], + ATTR_STATE: cdc_data['name'], + }) + self._state = cdc_data[self._kind] + elif self._category == CATEGORY_USER_REPORT and user_data: + self._attrs.update({ + ATTR_CITY: user_data['city'].split('(')[0], + ATTR_REPORTED_LATITUDE: user_data['latitude'], + ATTR_REPORTED_LONGITUDE: user_data['longitude'], + ATTR_ZIP_CODE: user_data['zip'], + }) + + if self._kind == TYPE_USER_TOTAL: + self._state = sum( + v for k, v in user_data.items() if k in ( + TYPE_USER_CHICK, TYPE_USER_DENGUE, TYPE_USER_FLU, + TYPE_USER_LEPTO, TYPE_USER_SYMPTOMS)) + else: + self._state = user_data[self._kind] + + +class FluNearYouData: + """Define a data object to retrieve info from Flu Near You.""" + + def __init__(self, client, sensor_types): + """Initialize.""" + self._client = client + self._sensor_types = sensor_types + self.data = {} + + async def _get_data(self, category, method): + """Get data for a specific category.""" + from pyflunearyou.errors import FluNearYouError + + try: + self.data[category] = await method() + except FluNearYouError as err: + _LOGGER.error( + 'There was an error with "%s" data: %s', category, err) + self.data[category] = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update Flu Near You data.""" + if CATEGORY_CDC_REPORT in self._sensor_types: + await self._get_data( + CATEGORY_CDC_REPORT, self._client.cdc_reports.status) + + if CATEGORY_USER_REPORT in self._sensor_types: + await self._get_data( + CATEGORY_USER_REPORT, self._client.user_reports.status) + + _LOGGER.debug('New data stored: %s', self.data) diff --git a/requirements_all.txt b/requirements_all.txt index d8a41643e60..701ad2f2b44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -921,6 +921,9 @@ pyflexit==0.3 # homeassistant.components.binary_sensor.flic pyflic-homeassistant==0.4.dev0 +# homeassistant.components.sensor.flunearyou +pyflunearyou==0.0.2 + # homeassistant.components.light.futurenow pyfnip==0.2 From d34c47a9e1e045821ac60393c2741f1ab79e3dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 9 Nov 2018 22:24:26 +0100 Subject: [PATCH 107/238] Rename sensor.launch to sensor.launch_library (#18337) --- .coveragerc | 2 +- .../components/sensor/{launch.py => launch_library.py} | 10 +++++----- requirements_all.txt | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) rename homeassistant/components/sensor/{launch.py => launch_library.py} (91%) diff --git a/.coveragerc b/.coveragerc index 189785e906e..bd48eab5241 100644 --- a/.coveragerc +++ b/.coveragerc @@ -732,7 +732,7 @@ omit = homeassistant/components/sensor/kwb.py homeassistant/components/sensor/lacrosse.py homeassistant/components/sensor/lastfm.py - homeassistant/components/sensor/launch.py + homeassistant/components/sensor/launch_library.py homeassistant/components/sensor/linky.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py diff --git a/homeassistant/components/sensor/launch.py b/homeassistant/components/sensor/launch_library.py similarity index 91% rename from homeassistant/components/sensor/launch.py rename to homeassistant/components/sensor/launch_library.py index ce883d94cd3..0d109006818 100644 --- a/homeassistant/components/sensor/launch.py +++ b/homeassistant/components/sensor/launch_library.py @@ -2,7 +2,7 @@ A sensor platform that give you information about the next space launch. For more details about this platform, please refer to the documentation at -https://www.home-assistant.io/components/sensor.launch/ +https://www.home-assistant.io/components/sensor.launch_library/ """ from datetime import timedelta import logging @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Data provided by Launch Library." -DEFAULT_NAME = 'Launch' +DEFAULT_NAME = 'Next launch' SCAN_INTERVAL = timedelta(hours=1) @@ -39,12 +39,12 @@ async def async_setup_platform( session = async_get_clientsession(hass) launches = Launches(hass.loop, session) - sensor = [LaunchSensor(launches, name)] + sensor = [LaunchLibrarySensor(launches, name)] async_add_entities(sensor, True) -class LaunchSensor(Entity): - """Representation of a launch Sensor.""" +class LaunchLibrarySensor(Entity): + """Representation of a launch_library Sensor.""" def __init__(self, launches, name): """Initialize the sensor.""" diff --git a/requirements_all.txt b/requirements_all.txt index 701ad2f2b44..b7f155b8ed2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -991,7 +991,7 @@ pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm pylast==2.4.0 -# homeassistant.components.sensor.launch +# homeassistant.components.sensor.launch_library pylaunches==0.1.2 # homeassistant.components.media_player.lg_netcast From 14ad5c00066e717d44a74e759e9c7f53147fc1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 9 Nov 2018 23:41:44 +0100 Subject: [PATCH 108/238] Switchmate library update (#18336) --- homeassistant/components/switch/switchmate.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index 7f00964cd20..e2ca3accdc9 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['pySwitchmate==0.4.2'] +REQUIREMENTS = ['pySwitchmate==0.4.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b7f155b8ed2..354cf822a80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -809,7 +809,7 @@ pyMetno==0.3.0 pyRFXtrx==0.23 # homeassistant.components.switch.switchmate -pySwitchmate==0.4.2 +pySwitchmate==0.4.3 # homeassistant.components.tibber pyTibber==0.7.4 From 64ada1ea5a51721ee453e4cf32b9a7e7e7324245 Mon Sep 17 00:00:00 2001 From: uchagani Date: Fri, 9 Nov 2018 20:04:28 -0500 Subject: [PATCH 109/238] bump total connect client to 0.22 (#18344) --- 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 2989bb1be37..97f46cb0dfd 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS) -REQUIREMENTS = ['total_connect_client==0.20'] +REQUIREMENTS = ['total_connect_client==0.22'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 354cf822a80..321c39118fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1517,7 +1517,7 @@ todoist-python==7.0.17 toonlib==1.1.3 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.20 +total_connect_client==0.22 # homeassistant.components.tplink_lte tp-connected==0.0.4 From 210eab16dadf72a610b69bd96d21dc61aabbdf62 Mon Sep 17 00:00:00 2001 From: Adam Belebczuk Date: Sat, 10 Nov 2018 02:17:24 -0500 Subject: [PATCH 110/238] WeMo - Change name of discovery option (#18348) --- homeassistant/components/wemo.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index ab2094ba9d7..93760405e08 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -58,17 +58,17 @@ def coerce_host_port(value): CONF_STATIC = 'static' -CONF_DISABLE_DISCOVERY = 'disable_discovery' +CONF_DISCOVERY = 'discovery' -DEFAULT_DISABLE_DISCOVERY = False +DEFAULT_DISCOVERY = True CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_STATIC, default=[]): vol.Schema([ vol.All(cv.string, coerce_host_port) ]), - vol.Optional(CONF_DISABLE_DISCOVERY, - default=DEFAULT_DISABLE_DISCOVERY): cv.boolean + vol.Optional(CONF_DISCOVERY, + default=DEFAULT_DISCOVERY): cv.boolean }), }, extra=vol.ALLOW_EXTRA) @@ -141,9 +141,7 @@ def setup(hass, config): devices.append((url, device)) - disable_discovery = config.get(DOMAIN, {}).get(CONF_DISABLE_DISCOVERY) - - if not disable_discovery: + if config.get(DOMAIN, {}).get(CONF_DISCOVERY): _LOGGER.debug("Scanning for WeMo devices.") devices.extend( (setup_url_for_device(device), device) From 8bd281d5a32bbac398d6bd06a6a7da7be74337af Mon Sep 17 00:00:00 2001 From: Tyler Page Date: Sat, 10 Nov 2018 01:21:39 -0600 Subject: [PATCH 111/238] Update credstash.py (#18349) * Update credstash.py * Update requirements_all.txt --- 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 84ba20619d8..302910c5b08 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.14.0', 'botocore==1.7.34'] +REQUIREMENTS = ['credstash==1.15.0', 'botocore==1.7.34'] def run(args): diff --git a/requirements_all.txt b/requirements_all.txt index 321c39118fa..88b78be0011 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -267,7 +267,7 @@ concord232==0.15 construct==2.9.45 # homeassistant.scripts.credstash -# credstash==1.14.0 +# credstash==1.15.0 # homeassistant.components.sensor.crimereports crimereports==1.0.0 From 132bb7902a74bf6ac2724a114c581f901760424e Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Sat, 10 Nov 2018 18:33:45 +0100 Subject: [PATCH 112/238] Update HAP-python to 2.4.0 (#18355) --- homeassistant/components/homekit/__init__.py | 2 +- homeassistant/components/homekit/type_switches.py | 7 ++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index f8514a5d030..650fff45c7b 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -29,7 +29,7 @@ from .const import ( from .util import ( show_setup_message, validate_entity_config, validate_media_player_features) -REQUIREMENTS = ['HAP-python==2.2.2'] +REQUIREMENTS = ['HAP-python==2.4.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 553d74f5a52..b41e1a01543 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -2,7 +2,8 @@ import logging from pyhap.const import ( - CATEGORY_OUTLET, CATEGORY_SWITCH) + CATEGORY_FAUCET, CATEGORY_OUTLET, CATEGORY_SHOWER_HEAD, + CATEGORY_SPRINKLER, CATEGORY_SWITCH) from homeassistant.components.script import ATTR_CAN_CANCEL from homeassistant.components.switch import DOMAIN @@ -20,10 +21,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -CATEGORY_SPRINKLER = 28 -CATEGORY_FAUCET = 29 -CATEGORY_SHOWER_HEAD = 30 - VALVE_TYPE = { TYPE_FAUCET: (CATEGORY_FAUCET, 3), TYPE_SHOWER: (CATEGORY_SHOWER_HEAD, 2), diff --git a/requirements_all.txt b/requirements_all.txt index 88b78be0011..1ec88c98fa0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -32,7 +32,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.0.0 # homeassistant.components.homekit -HAP-python==2.2.2 +HAP-python==2.4.0 # homeassistant.components.notify.mastodon Mastodon.py==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a6470fa474..4011be94bb6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ requests_mock==1.5.2 # homeassistant.components.homekit -HAP-python==2.2.2 +HAP-python==2.4.0 # homeassistant.components.sensor.rmvtransport PyRMVtransport==0.1.3 From e75f9b36f907b747bc364fdee86bdd14340561d0 Mon Sep 17 00:00:00 2001 From: Antoine Meillet Date: Sat, 10 Nov 2018 21:08:03 +0100 Subject: [PATCH 113/238] add heartbeat support to mysensors (#18359) --- homeassistant/components/mysensors/device.py | 2 ++ homeassistant/components/mysensors/handler.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 7f4f6100204..07261b1c2a6 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -15,6 +15,7 @@ ATTR_CHILD_ID = 'child_id' ATTR_DESCRIPTION = 'description' ATTR_DEVICE = 'device' ATTR_NODE_ID = 'node_id' +ATTR_HEARTBEAT = 'heartbeat' MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' @@ -51,6 +52,7 @@ class MySensorsDevice: child = node.children[self.child_id] attr = { ATTR_BATTERY_LEVEL: node.battery_level, + ATTR_HEARTBEAT: node.heartbeat, ATTR_CHILD_ID: self.child_id, ATTR_DESCRIPTION: child.description, ATTR_DEVICE: self.gateway.device, diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index 3403c589639..39af1173706 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -45,6 +45,12 @@ async def handle_battery_level(hass, hass_config, msg): _handle_node_update(hass, msg) +@HANDLERS.register('I_HEARTBEAT_RESPONSE') +async def handle_heartbeat(hass, hass_config, msg): + """Handle an heartbeat.""" + _handle_node_update(hass, msg) + + @HANDLERS.register('I_SKETCH_NAME') async def handle_sketch_name(hass, hass_config, msg): """Handle an internal sketch name message.""" From f236e14bd6bd71554f1e1529d9c7a259cec55fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 10 Nov 2018 22:08:32 +0200 Subject: [PATCH 114/238] Upgrade pytest and pytest-sugar (#18338) * Upgrade pytest to 3.10.0 * Upgrade pytest-sugar to 0.9.2 --- requirements_test.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 68248a47cdb..9f34d9ee861 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ pydocstyle==2.1.1 pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 -pytest-sugar==0.9.1 +pytest-sugar==0.9.2 pytest-timeout==1.3.2 -pytest==3.9.3 +pytest==3.10.0 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4011be94bb6..8fd96db4942 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -12,9 +12,9 @@ pydocstyle==2.1.1 pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 -pytest-sugar==0.9.1 +pytest-sugar==0.9.2 pytest-timeout==1.3.2 -pytest==3.9.3 +pytest==3.10.0 requests_mock==1.5.2 From 667b41dd4a81e3d41b5838859a3554b8fb1ba2ef Mon Sep 17 00:00:00 2001 From: Chris Kacerguis Date: Sat, 10 Nov 2018 14:30:03 -0600 Subject: [PATCH 115/238] Show battery_level as a percent vs a decimal (#18328) --- homeassistant/components/wirelesstag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wirelesstag.py b/homeassistant/components/wirelesstag.py index f2832100066..77b4c48b41b 100644 --- a/homeassistant/components/wirelesstag.py +++ b/homeassistant/components/wirelesstag.py @@ -271,7 +271,7 @@ class WirelessTagBaseSensor(Entity): def device_state_attributes(self): """Return the state attributes.""" return { - ATTR_BATTERY_LEVEL: self._tag.battery_remaining, + ATTR_BATTERY_LEVEL: int(self._tag.battery_remaining*100), ATTR_VOLTAGE: '{:.2f}V'.format(self._tag.battery_volts), ATTR_TAG_SIGNAL_STRENGTH: '{}dBm'.format( self._tag.signal_strength), From fa127188df1c20c5fe34c8d514fe677ec28df99b Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Sat, 10 Nov 2018 12:17:14 -0800 Subject: [PATCH 116/238] Bump python-avion dependency The current version of python-avion doesn't work correctly with Python 3.5. Update it to one that does. --- homeassistant/components/light/avion.py | 2 +- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/avion.py b/homeassistant/components/light/avion.py index 731f0e600fb..00fc4f33741 100644 --- a/homeassistant/components/light/avion.py +++ b/homeassistant/components/light/avion.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['antsar-avion==0.9.1'] +REQUIREMENTS = ['avion==0.10'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 1ec88c98fa0..77bb6646894 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -140,9 +140,6 @@ anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.media_player.anthemav anthemav==1.1.8 -# homeassistant.components.light.avion -# antsar-avion==0.9.1 - # homeassistant.components.apcupsd apcaccess==0.0.13 @@ -159,6 +156,9 @@ asterisk_mbox==0.5.0 # homeassistant.components.media_player.dlna_dmr async-upnp-client==0.13.1 +# homeassistant.components.light.avion +# avion==0.10 + # homeassistant.components.axis axis==16 From f0a0ce504b17cdefbf71c5ad01cdc08d35eb2090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 11 Nov 2018 14:06:21 +0100 Subject: [PATCH 117/238] Better error handling in Tibber (#18363) * Better error handling in Tibber * return if received error --- homeassistant/components/sensor/tibber.py | 12 ++++++++++-- homeassistant/components/tibber/__init__.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 1207c8dfe20..861fd6eff53 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -171,8 +171,16 @@ class TibberSensorRT(Entity): async def _async_callback(self, payload): """Handle received data.""" - data = payload.get('data', {}) - live_measurement = data.get('liveMeasurement', {}) + errors = payload.get('errors') + if errors: + _LOGGER.error(errors[0]) + return + data = payload.get('data') + if data is None: + return + live_measurement = data.get('liveMeasurement') + if live_measurement is None: + return self._state = live_measurement.pop('power', None) self._device_state_attributes = live_measurement self.async_schedule_update_ha_state() diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 38605e949bb..8e824c0e2c2 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.7.4'] +REQUIREMENTS = ['pyTibber==0.7.5'] DOMAIN = 'tibber' diff --git a/requirements_all.txt b/requirements_all.txt index 77bb6646894..f9922bbf039 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -812,7 +812,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.3 # homeassistant.components.tibber -pyTibber==0.7.4 +pyTibber==0.7.5 # homeassistant.components.switch.dlink pyW215==0.6.0 From 9c92151ad16745c4c00df6655dccf9ea1bf25ee5 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sun, 11 Nov 2018 16:10:03 +0100 Subject: [PATCH 118/238] Upgrade async_upnp_client to 0.13.2 (#18377) --- homeassistant/components/media_player/dlna_dmr.py | 2 +- homeassistant/components/upnp/__init__.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py index 5869cf2dbf9..941b8844f86 100644 --- a/homeassistant/components/media_player/dlna_dmr.py +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -26,7 +26,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -REQUIREMENTS = ['async-upnp-client==0.13.1'] +REQUIREMENTS = ['async-upnp-client==0.13.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 1651879fd2c..925ca561eb9 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -29,7 +29,7 @@ from .config_flow import async_ensure_domain_data from .device import Device -REQUIREMENTS = ['async-upnp-client==0.13.1'] +REQUIREMENTS = ['async-upnp-client==0.13.2'] NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP/IGD Setup' diff --git a/requirements_all.txt b/requirements_all.txt index f9922bbf039..e5ef1ce8410 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -154,7 +154,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.upnp # homeassistant.components.media_player.dlna_dmr -async-upnp-client==0.13.1 +async-upnp-client==0.13.2 # homeassistant.components.light.avion # avion==0.10 From b8c06ad019a1a97e434d0bd703f07b98cdc748aa Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sun, 11 Nov 2018 17:15:58 +0100 Subject: [PATCH 119/238] Fix including from sub dir (#18378) The include path is now always relative to the root of the config dir. --- homeassistant/util/ruamel_yaml.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py index eb3e935c6ce..8211252a516 100644 --- a/homeassistant/util/ruamel_yaml.py +++ b/homeassistant/util/ruamel_yaml.py @@ -80,7 +80,8 @@ def load_yaml(fname: str, round_trip: bool = False) -> JSON_TYPE: yaml = YAML(typ='rt') yaml.preserve_quotes = True else: - ExtSafeConstructor.name = fname + if not hasattr(ExtSafeConstructor, 'name'): + ExtSafeConstructor.name = fname yaml = YAML(typ='safe') yaml.Constructor = ExtSafeConstructor From 9411fca955a4ba301b7b8b0741b018fcad27873a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 11 Nov 2018 18:39:50 +0200 Subject: [PATCH 120/238] Add more type hints to helpers (#18350) * Add type hints to helpers.entityfilter * Add type hints to helpers.deprecation --- homeassistant/helpers/deprecation.py | 13 +++++++------ homeassistant/helpers/entityfilter.py | 21 ++++++++++++--------- tox.ini | 2 +- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 8b621b2f01c..6ed7cbb9b51 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -1,9 +1,10 @@ """Deprecation helpers for Home Assistant.""" import inspect import logging +from typing import Any, Callable, Dict, Optional -def deprecated_substitute(substitute_name): +def deprecated_substitute(substitute_name: str) -> Callable[..., Callable]: """Help migrate properties to new names. When a property is added to replace an older property, this decorator can @@ -11,9 +12,9 @@ def deprecated_substitute(substitute_name): If the old property is defined, its value will be used instead, and a log warning will be issued alerting the user of the impending change. """ - def decorator(func): + def decorator(func: Callable) -> Callable: """Decorate function as deprecated.""" - def func_wrapper(self): + def func_wrapper(self: Callable) -> Any: """Wrap for the original function.""" if hasattr(self, substitute_name): # If this platform is still using the old property, issue @@ -28,8 +29,7 @@ def deprecated_substitute(substitute_name): substitute_name, substitute_name, func.__name__, inspect.getfile(self.__class__)) warnings[module_name] = True - # pylint: disable=protected-access - func._deprecated_substitute_warnings = warnings + setattr(func, '_deprecated_substitute_warnings', warnings) # Return the old property return getattr(self, substitute_name) @@ -38,7 +38,8 @@ def deprecated_substitute(substitute_name): return decorator -def get_deprecated(config, new_name, old_name, default=None): +def get_deprecated(config: Dict[str, Any], new_name: str, old_name: str, + default: Optional[Any] = None) -> Optional[Any]: """Allow an old config name to be deprecated with a replacement. If the new config isn't found, but the old one is, the old value is used diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 141fc912275..7db577dfdc6 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,4 +1,5 @@ """Helper class to implement include/exclude of entities and domains.""" +from typing import Callable, Dict, Iterable import voluptuous as vol @@ -11,14 +12,14 @@ CONF_EXCLUDE_DOMAINS = 'exclude_domains' CONF_EXCLUDE_ENTITIES = 'exclude_entities' -def _convert_filter(config): +def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]: filt = generate_filter( config[CONF_INCLUDE_DOMAINS], config[CONF_INCLUDE_ENTITIES], config[CONF_EXCLUDE_DOMAINS], config[CONF_EXCLUDE_ENTITIES], ) - filt.config = config + setattr(filt, 'config', config) return filt @@ -33,8 +34,10 @@ FILTER_SCHEMA = vol.All( }), _convert_filter) -def generate_filter(include_domains, include_entities, - exclude_domains, exclude_entities): +def generate_filter(include_domains: Iterable[str], + include_entities: Iterable[str], + exclude_domains: Iterable[str], + exclude_entities: Iterable[str]) -> Callable[[str], bool]: """Return a function that will filter entities based on the args.""" include_d = set(include_domains) include_e = set(include_entities) @@ -50,7 +53,7 @@ def generate_filter(include_domains, include_entities, # Case 2 - includes, no excludes - only include specified entities if have_include and not have_exclude: - def entity_filter_2(entity_id): + def entity_filter_2(entity_id: str) -> bool: """Return filter function for case 2.""" domain = split_entity_id(entity_id)[0] return (entity_id in include_e or @@ -60,7 +63,7 @@ def generate_filter(include_domains, include_entities, # Case 3 - excludes, no includes - only exclude specified entities if not have_include and have_exclude: - def entity_filter_3(entity_id): + def entity_filter_3(entity_id: str) -> bool: """Return filter function for case 3.""" domain = split_entity_id(entity_id)[0] return (entity_id not in exclude_e and @@ -75,7 +78,7 @@ def generate_filter(include_domains, include_entities, # note: if both include and exclude domains specified, # the exclude domains are ignored if include_d: - def entity_filter_4a(entity_id): + def entity_filter_4a(entity_id: str) -> bool: """Return filter function for case 4a.""" domain = split_entity_id(entity_id)[0] if domain in include_d: @@ -88,7 +91,7 @@ def generate_filter(include_domains, include_entities, # - if domain is excluded, pass if entity is included # - if domain is not excluded, pass if entity not excluded if exclude_d: - def entity_filter_4b(entity_id): + def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] if domain in exclude_d: @@ -99,7 +102,7 @@ def generate_filter(include_domains, include_entities, # Case 4c - neither include or exclude domain specified # - Only pass if entity is included. Ignore entity excludes. - def entity_filter_4c(entity_id): + def entity_filter_4c(entity_id: str) -> bool: """Return filter function for case 4c.""" return entity_id in include_e diff --git a/tox.ini b/tox.ini index f5dee78893f..1ab771ff24b 100644 --- a/tox.ini +++ b/tox.ini @@ -60,4 +60,4 @@ whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,dispatcher,entity_values,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' + /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,deprecation,dispatcher,entity_values,entityfilter,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' From 5b9a9d8e04afa4aecf99925f675b13f74852ff66 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Sun, 11 Nov 2018 16:43:01 +0000 Subject: [PATCH 121/238] Return color information in Alexa Smart Home response (#18368) Fixes #18367. --- homeassistant/components/alexa/smart_home.py | 20 +++++++++++++++ tests/components/alexa/test_smart_home.py | 26 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 99d2a50bee0..9496bf6804c 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -474,6 +474,26 @@ class _AlexaColorController(_AlexaInterface): def name(self): return 'Alexa.ColorController' + def properties_supported(self): + return [{'name': 'color'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'color': + raise _UnsupportedProperty(name) + + hue, saturation = self.entity.attributes.get( + light.ATTR_HS_COLOR, (0, 0)) + + return { + 'hue': hue, + 'saturation': saturation / 100.0, + 'brightness': self.entity.attributes.get( + light.ATTR_BRIGHTNESS, 0) / 255.0, + } + class _AlexaColorTemperatureController(_AlexaInterface): """Implements Alexa.ColorTemperatureController. diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 4ea06b57a38..766075f8eb5 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1328,6 +1328,32 @@ async def test_report_dimmable_light_state(hass): properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) +async def test_report_colored_light_state(hass): + """Test ColorController reports color correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'hs_color': (180, 75), + 'brightness': 128, + 'supported_features': 17}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 17}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 180, + 'saturation': 0.75, + 'brightness': 128 / 255.0, + }) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 0, + 'saturation': 0, + 'brightness': 0, + }) + + async def reported_properties(hass, endpoint): """Use ReportState to get properties and return them. From 9cb6464c587076e581233e3b009adc71837abc42 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sun, 11 Nov 2018 11:44:41 -0500 Subject: [PATCH 122/238] catch key error when saving image (#18365) --- homeassistant/components/image_processing/tensorflow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index a2cd997bb76..2d06dbbcf34 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -246,7 +246,8 @@ class TensorFlowImageProcessor(ImageProcessingEntity): for category, values in matches.items(): # Draw custom category regions/areas - if self._category_areas[category] != [0, 0, 1, 1]: + if (category in self._category_areas + and self._category_areas[category] != [0, 0, 1, 1]): label = "{} Detection Area".format(category.capitalize()) draw_box(draw, self._category_areas[category], img_width, img_height, label, (0, 255, 0)) From 02cc6a2f9a564b7a021c6e7f84e397000bd38738 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 11 Nov 2018 17:46:28 +0100 Subject: [PATCH 123/238] Fix hangouts notify (#18372) * Remove notify schema from hangouts platform * Notify platforms shouldn't overwrite the notify component service schema. That has no effect. * Fix hangouts service data key value --- homeassistant/components/notify/hangouts.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/notify/hangouts.py b/homeassistant/components/notify/hangouts.py index 01f98146f4c..7261663b99f 100644 --- a/homeassistant/components/notify/hangouts.py +++ b/homeassistant/components/notify/hangouts.py @@ -9,13 +9,12 @@ import logging import voluptuous as vol from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA, - NOTIFY_SERVICE_SCHEMA, BaseNotificationService, ATTR_MESSAGE, ATTR_DATA) from homeassistant.components.hangouts.const \ - import (DOMAIN, SERVICE_SEND_MESSAGE, MESSAGE_DATA_SCHEMA, - TARGETS_SCHEMA, CONF_DEFAULT_CONVERSATIONS) + import (DOMAIN, SERVICE_SEND_MESSAGE, TARGETS_SCHEMA, + CONF_DEFAULT_CONVERSATIONS) _LOGGER = logging.getLogger(__name__) @@ -25,11 +24,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEFAULT_CONVERSATIONS): [TARGETS_SCHEMA] }) -NOTIFY_SERVICE_SCHEMA = NOTIFY_SERVICE_SCHEMA.extend({ - vol.Optional(ATTR_TARGET): [TARGETS_SCHEMA], - vol.Optional(ATTR_DATA, default={}): MESSAGE_DATA_SCHEMA -}) - def get_service(hass, config, discovery_info=None): """Get the Hangouts notification service.""" @@ -61,8 +55,9 @@ class HangoutsNotificationService(BaseNotificationService): service_data = { ATTR_TARGET: target_conversations, ATTR_MESSAGE: messages, - ATTR_DATA: kwargs[ATTR_DATA] } + if kwargs[ATTR_DATA]: + service_data[ATTR_DATA] = kwargs[ATTR_DATA] return self.hass.services.call( DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data) From 372470f52aecfdbd85ed7654d76d314014d46e8d Mon Sep 17 00:00:00 2001 From: bouni Date: Sun, 11 Nov 2018 17:48:44 +0100 Subject: [PATCH 124/238] Fix and improvment of Swiss Hydrological Data component (#17166) * Fix and improvment of Swiss Hydrological Data component * changed component to get data from a REST API rather than from crawling the website * fixed several issues and lint errors * Fix and improvment of Swiss Hydrological Data component * Minor changes - Simplify the sensor configuration (expose value as attributes rather than sensor) - Make the setup fail if station is not available - Add unique ID - Prepare for config flow --- .../sensor/swiss_hydrological_data.py | 221 +++++++++--------- requirements_all.txt | 4 +- 2 files changed, 108 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/sensor/swiss_hydrological_data.py b/homeassistant/components/sensor/swiss_hydrological_data.py index fb55c22b2e8..c354ebedb2b 100644 --- a/homeassistant/components/sensor/swiss_hydrological_data.py +++ b/homeassistant/components/sensor/swiss_hydrological_data.py @@ -4,145 +4,160 @@ Support for hydrological data from the Federal Office for the Environment FOEN. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.swiss_hydrological_data/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol -import requests from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - TEMP_CELSIUS, CONF_NAME, STATE_UNKNOWN, ATTR_ATTRIBUTION) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['xmltodict==0.11.0'] +REQUIREMENTS = ['swisshydrodata==0.0.3'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'http://www.hydrodata.ch/xml/SMS.xml' + +ATTRIBUTION = "Data provided by the Swiss Federal Office for the " \ + "Environment FOEN" + +ATTR_DELTA_24H = 'delta-24h' +ATTR_MAX_1H = 'max-1h' +ATTR_MAX_24H = 'max-24h' +ATTR_MEAN_1H = 'mean-1h' +ATTR_MEAN_24H = 'mean-24h' +ATTR_MIN_1H = 'min-1h' +ATTR_MIN_24H = 'min-24h' +ATTR_PREVIOUS_24H = 'previous-24h' +ATTR_STATION = 'station' +ATTR_STATION_UPDATE = 'station_update' +ATTR_WATER_BODY = 'water_body' +ATTR_WATER_BODY_TYPE = 'water_body_type' CONF_STATION = 'station' -CONF_ATTRIBUTION = "Data provided by the Swiss Federal Office for the " \ - "Environment FOEN" -DEFAULT_NAME = 'Water temperature' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -ICON = 'mdi:cup-water' +SENSOR_DISCHARGE = 'discharge' +SENSOR_LEVEL = 'level' +SENSOR_TEMPERATURE = 'temperature' -ATTR_LOCATION = 'location' -ATTR_UPDATE = 'update' -ATTR_DISCHARGE = 'discharge' -ATTR_WATERLEVEL = 'level' -ATTR_DISCHARGE_MEAN = 'discharge_mean' -ATTR_WATERLEVEL_MEAN = 'level_mean' -ATTR_TEMPERATURE_MEAN = 'temperature_mean' -ATTR_DISCHARGE_MAX = 'discharge_max' -ATTR_WATERLEVEL_MAX = 'level_max' -ATTR_TEMPERATURE_MAX = 'temperature_max' +CONDITIONS = { + SENSOR_DISCHARGE: 'mdi:waves', + SENSOR_LEVEL: 'mdi:zodiac-aquarius', + SENSOR_TEMPERATURE: 'mdi:oil-temperature', +} -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) +CONDITION_DETAILS = [ + ATTR_DELTA_24H, + ATTR_MAX_1H, + ATTR_MAX_24H, + ATTR_MEAN_1H, + ATTR_MEAN_24H, + ATTR_MIN_1H, + ATTR_MIN_24H, + ATTR_PREVIOUS_24H, +] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STATION): vol.Coerce(int), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[SENSOR_TEMPERATURE]): + vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), }) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Swiss hydrological sensor.""" - import xmltodict - - name = config.get(CONF_NAME) station = config.get(CONF_STATION) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) - try: - response = requests.get(_RESOURCE, timeout=5) - if any(str(station) == location.get('@StrNr') for location in - xmltodict.parse(response.text)['AKT_Data']['MesPar']) is False: - _LOGGER.error("The given station does not exist: %s", station) - return False - except requests.exceptions.ConnectionError: - _LOGGER.error("The URL is not accessible") - return False + hydro_data = HydrologicalData(station) + hydro_data.update() - data = HydrologicalData(station) - add_entities([SwissHydrologicalDataSensor(name, data)], True) + if hydro_data.data is None: + _LOGGER.error("The station doesn't exists: %s", station) + return + + entities = [] + + for condition in monitored_conditions: + entities.append( + SwissHydrologicalDataSensor(hydro_data, station, condition)) + + add_entities(entities, True) class SwissHydrologicalDataSensor(Entity): - """Implementation of an Swiss hydrological sensor.""" + """Implementation of a Swiss hydrological sensor.""" - def __init__(self, name, data): - """Initialize the sensor.""" - self.data = data - self._name = name - self._unit_of_measurement = TEMP_CELSIUS - self._state = None + def __init__(self, hydro_data, station, condition): + """Initialize the Swiss hydrological sensor.""" + self.hydro_data = hydro_data + self._condition = condition + self._data = self._state = self._unit_of_measurement = None + self._icon = CONDITIONS[condition] + self._station = station @property def name(self): """Return the name of the sensor.""" - return self._name + return "{0} {1}".format(self._data['water-body-name'], self._condition) + + @property + def unique_id(self) -> str: + """Return a unique, friendly identifier for this entity.""" + return '{0}_{1}'.format(self._station, self._condition) @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - if self._state is not STATE_UNKNOWN: - return self._unit_of_measurement + if self._state is not None: + return self.hydro_data.data['parameters'][self._condition]['unit'] return None @property def state(self): """Return the state of the sensor.""" - try: - return round(float(self._state), 1) - except ValueError: - return STATE_UNKNOWN + if isinstance(self._state, (int, float)): + return round(self._state, 2) + return None @property def device_state_attributes(self): - """Return the state attributes.""" - attributes = {} - if self.data.measurings is not None: - if '02' in self.data.measurings: - attributes[ATTR_WATERLEVEL] = self.data.measurings['02'][ - 'current'] - attributes[ATTR_WATERLEVEL_MEAN] = self.data.measurings['02'][ - 'mean'] - attributes[ATTR_WATERLEVEL_MAX] = self.data.measurings['02'][ - 'max'] - if '03' in self.data.measurings: - attributes[ATTR_TEMPERATURE_MEAN] = self.data.measurings['03'][ - 'mean'] - attributes[ATTR_TEMPERATURE_MAX] = self.data.measurings['03'][ - 'max'] - if '10' in self.data.measurings: - attributes[ATTR_DISCHARGE] = self.data.measurings['10'][ - 'current'] - attributes[ATTR_DISCHARGE_MEAN] = self.data.measurings['10'][ - 'current'] - attributes[ATTR_DISCHARGE_MAX] = self.data.measurings['10'][ - 'max'] + """Return the device state attributes.""" + attrs = {} - attributes[ATTR_LOCATION] = self.data.measurings['location'] - attributes[ATTR_UPDATE] = self.data.measurings['update_time'] - attributes[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - return attributes + if not self._data: + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + return attrs + + attrs[ATTR_WATER_BODY_TYPE] = self._data['water-body-type'] + attrs[ATTR_STATION] = self._data['name'] + attrs[ATTR_STATION_UPDATE] = \ + self._data['parameters'][self._condition]['datetime'] + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + + for entry in CONDITION_DETAILS: + attrs[entry.replace('-', '_')] = \ + self._data['parameters'][self._condition][entry] + + return attrs @property def icon(self): - """Icon to use in the frontend, if any.""" - return ICON + """Icon to use in the frontend.""" + return self._icon def update(self): - """Get the latest data and update the states.""" - self.data.update() - if self.data.measurings is not None: - if '03' not in self.data.measurings: - self._state = STATE_UNKNOWN - else: - self._state = self.data.measurings['03']['current'] + """Get the latest data and update the state.""" + self.hydro_data.update() + self._data = self.hydro_data.data + + if self._data is None: + self._state = None + else: + self._state = self._data['parameters'][self._condition]['value'] class HydrologicalData: @@ -151,38 +166,12 @@ class HydrologicalData: def __init__(self, station): """Initialize the data object.""" self.station = station - self.measurings = None + self.data = None @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Get the latest data from hydrodata.ch.""" - import xmltodict + """Get the latest data.""" + from swisshydrodata import SwissHydroData - details = {} - try: - response = requests.get(_RESOURCE, timeout=5) - except requests.exceptions.ConnectionError: - _LOGGER.error("Unable to retrieve data from %s", _RESOURCE) - - try: - stations = xmltodict.parse(response.text)['AKT_Data']['MesPar'] - # Water level: Typ="02", temperature: Typ="03", discharge: Typ="10" - for station in stations: - if str(self.station) != station.get('@StrNr'): - continue - for data in ['02', '03', '10']: - if data != station.get('@Typ'): - continue - values = station.get('Wert') - if values is not None: - details[data] = { - 'current': values[0], - 'max': list(values[4].items())[1][1], - 'mean': list(values[3].items())[1][1]} - - details['location'] = station.get('Name') - details['update_time'] = station.get('Zeit') - - self.measurings = details - except AttributeError: - self.measurings = None + shd = SwissHydroData() + self.data = shd.get_station(self.station) diff --git a/requirements_all.txt b/requirements_all.txt index e5ef1ce8410..459fd365c08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1474,6 +1474,9 @@ suds-passworddigest-homeassistant==0.1.2a0.dev0 # homeassistant.components.camera.onvif suds-py3==1.3.3.0 +# homeassistant.components.sensor.swiss_hydrological_data +swisshydrodata==0.0.3 + # homeassistant.components.tahoma tahoma-api==0.0.13 @@ -1606,7 +1609,6 @@ xknx==0.9.1 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.startca -# homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 # homeassistant.components.sensor.yr # homeassistant.components.sensor.zestimate From 95eae474389287e06e060296c715fb82c8b5ce92 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 11 Nov 2018 18:22:28 +0100 Subject: [PATCH 125/238] Bump version to 0.83.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 50a27f9d9c8..72fc2165d28 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 = 82 +MINOR_VERSION = 83 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 5129a487506f43e08b1b07a71a8fadf83a1f6efb Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 11 Nov 2018 13:01:26 -0700 Subject: [PATCH 126/238] Fixed misspellings in some of the Pollen sensor names (#18382) --- homeassistant/components/sensor/pollen.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 818404aa3fe..7bbdd1ee0b5 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -62,11 +62,11 @@ SENSORS = { 'IndexSensor', 'Allergy Index: Tomorrow', 'mdi:flower'), TYPE_ALLERGY_YESTERDAY: ( 'IndexSensor', 'Allergy Index: Yesterday', 'mdi:flower'), - TYPE_ASTHMA_TODAY: ('IndexSensor', 'Ashma Index: Today', 'mdi:flower'), + TYPE_ASTHMA_TODAY: ('IndexSensor', 'Asthma Index: Today', 'mdi:flower'), TYPE_ASTHMA_TOMORROW: ( - 'IndexSensor', 'Ashma Index: Tomorrow', 'mdi:flower'), + 'IndexSensor', 'Asthma Index: Tomorrow', 'mdi:flower'), TYPE_ASTHMA_YESTERDAY: ( - 'IndexSensor', 'Ashma Index: Yesterday', 'mdi:flower'), + 'IndexSensor', 'Asthma Index: Yesterday', 'mdi:flower'), TYPE_ASTHMA_FORECAST: ( 'ForecastSensor', 'Asthma Index: Forecasted Average', 'mdi:flower'), TYPE_ASTHMA_HISTORIC: ( From ddeeba20b95f033b99185f12a1c09270624430d3 Mon Sep 17 00:00:00 2001 From: Eric Nagley Date: Sun, 11 Nov 2018 16:02:33 -0500 Subject: [PATCH 127/238] Google assistant enable fan speed controls (#18373) * Initial commit of Traits changes. * Initial commit of tests chagnes for added FanSpeed trait. * pylint fixes. * Default reversible to false * Ensure reversible returns True/False. * Fix FanSpeed trait name and fix order. * Add remaining checks to FanSpeed trait Test. * Remove un-needed blank lines at EOF. * Update homeassistant/components/google_assistant/trait.py Co-Authored-By: marchingphoenix * use fan.SPEED_* constants as keys to speed_synonyms dict. convert True if() to bool() for reversible assignment. * use fan.SPEED_OFF constant of 'on' check. --- .../components/google_assistant/trait.py | 79 +++++++++++++++- tests/components/google_assistant/__init__.py | 10 ++- .../components/google_assistant/test_trait.py | 90 +++++++++++++++++-- 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index ce13818d9de..d32dd91a3c1 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,7 +1,6 @@ """Implement the Smart Home traits.""" import logging -from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.components import ( climate, cover, @@ -26,8 +25,8 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ) +from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.util import color as color_util, temperature as temp_util - from .const import ERR_VALUE_OUT_OF_RANGE from .helpers import SmartHomeError @@ -43,6 +42,7 @@ TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' TRAIT_SCENE = PREFIX_TRAITS + 'Scene' TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock' +TRAIT_FANSPEED = PREFIX_TRAITS + 'FanSpeed' PREFIX_COMMANDS = 'action.devices.commands.' COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' @@ -58,6 +58,7 @@ COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock' +COMMAND_FANSPEED = PREFIX_COMMANDS + 'SetFanSpeed' TRAITS = [] @@ -675,3 +676,77 @@ class LockUnlockTrait(_Trait): await self.hass.services.async_call(lock.DOMAIN, service, { ATTR_ENTITY_ID: self.state.entity_id }, blocking=True) + + +@register_trait +class FanSpeedTrait(_Trait): + """Trait to control speed of Fan. + + https://developers.google.com/actions/smarthome/traits/fanspeed + """ + + name = TRAIT_FANSPEED + commands = [ + COMMAND_FANSPEED + ] + + speed_synonyms = { + fan.SPEED_OFF: ['stop', 'off'], + fan.SPEED_LOW: ['slow', 'low', 'slowest', 'lowest'], + fan.SPEED_MEDIUM: ['medium', 'mid', 'middle'], + fan.SPEED_HIGH: [ + 'high', 'max', 'fast', 'highest', 'fastest', 'maximum' + ] + } + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != fan.DOMAIN: + return False + + return features & fan.SUPPORT_SET_SPEED + + def sync_attributes(self): + """Return speed point and modes attributes for a sync request.""" + modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) + speeds = [] + for mode in modes: + speed = { + "speed_name": mode, + "speed_values": [{ + "speed_synonym": self.speed_synonyms.get(mode), + "lang": 'en' + }] + } + speeds.append(speed) + + return { + 'availableFanSpeeds': { + 'speeds': speeds, + 'ordered': True + }, + "reversible": bool(self.state.attributes.get( + ATTR_SUPPORTED_FEATURES, 0) & fan.SUPPORT_DIRECTION) + } + + def query_attributes(self): + """Return speed point and modes query attributes.""" + attrs = self.state.attributes + response = {} + + speed = attrs.get(fan.ATTR_SPEED) + if speed is not None: + response['on'] = speed != fan.SPEED_OFF + response['online'] = True + response['currentFanSpeedSetting'] = speed + + return response + + async def execute(self, command, params): + """Execute an SetFanSpeed command.""" + await self.hass.services.async_call( + fan.DOMAIN, fan.SERVICE_SET_SPEED, { + ATTR_ENTITY_ID: self.state.entity_id, + fan.ATTR_SPEED: params['fanSpeed'] + }, blocking=True) diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 5fd00abc411..1568919a9b4 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -183,7 +183,10 @@ DEMO_DEVICES = [{ 'name': { 'name': 'Living Room Fan' }, - 'traits': ['action.devices.traits.OnOff'], + 'traits': [ + 'action.devices.traits.FanSpeed', + 'action.devices.traits.OnOff' + ], 'type': 'action.devices.types.FAN', 'willReportState': False }, { @@ -191,7 +194,10 @@ DEMO_DEVICES = [{ 'name': { 'name': 'Ceiling Fan' }, - 'traits': ['action.devices.traits.OnOff'], + 'traits': [ + 'action.devices.traits.FanSpeed', + 'action.devices.traits.OnOff' + ], 'type': 'action.devices.types.FAN', 'willReportState': False }, { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index ff3ce65ee27..42af1230eed 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1,10 +1,6 @@ """Tests for the Google Assistant traits.""" import pytest -from homeassistant.const import ( - STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, - TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES) -from homeassistant.core import State, DOMAIN as HA_DOMAIN from homeassistant.components import ( climate, cover, @@ -20,8 +16,11 @@ from homeassistant.components import ( group, ) from homeassistant.components.google_assistant import trait, helpers, const +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, + TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES) +from homeassistant.core import State, DOMAIN as HA_DOMAIN from homeassistant.util import color - from tests.common import async_mock_service BASIC_CONFIG = helpers.Config( @@ -795,3 +794,84 @@ async def test_lock_unlock_unlock(hass): assert calls[0].data == { ATTR_ENTITY_ID: 'lock.front_door' } + + +async def test_fan_speed(hass): + """Test FanSpeed trait speed control support for fan domain.""" + assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED) + + trt = trait.FanSpeedTrait( + hass, State( + 'fan.living_room_fan', fan.SPEED_HIGH, attributes={ + 'speed_list': [ + fan.SPEED_OFF, fan.SPEED_LOW, fan.SPEED_MEDIUM, + fan.SPEED_HIGH + ], + 'speed': 'low' + }), BASIC_CONFIG) + + assert trt.sync_attributes() == { + 'availableFanSpeeds': { + 'ordered': True, + 'speeds': [ + { + 'speed_name': 'off', + 'speed_values': [ + { + 'speed_synonym': ['stop', 'off'], + 'lang': 'en' + } + ] + }, + { + 'speed_name': 'low', + 'speed_values': [ + { + 'speed_synonym': [ + 'slow', 'low', 'slowest', 'lowest'], + 'lang': 'en' + } + ] + }, + { + 'speed_name': 'medium', + 'speed_values': [ + { + 'speed_synonym': ['medium', 'mid', 'middle'], + 'lang': 'en' + } + ] + }, + { + 'speed_name': 'high', + 'speed_values': [ + { + 'speed_synonym': [ + 'high', 'max', 'fast', 'highest', 'fastest', + 'maximum'], + 'lang': 'en' + } + ] + } + ] + }, + 'reversible': False + } + + assert trt.query_attributes() == { + 'currentFanSpeedSetting': 'low', + 'on': True, + 'online': True + } + + assert trt.can_execute( + trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'}) + + calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED) + await trt.execute(trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'}) + + assert len(calls) == 1 + assert calls[0].data == { + 'entity_id': 'fan.living_room_fan', + 'speed': 'medium' + } From 1f476936a27454dc51dc6d59082c6243b537d4fe Mon Sep 17 00:00:00 2001 From: Clayton Nummer Date: Sun, 11 Nov 2018 16:55:45 -0500 Subject: [PATCH 128/238] Fix default value for optional Sense configuration parameter (#18379) --- homeassistant/components/sense.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sense.py b/homeassistant/components/sense.py index 3792e10e761..6e9204b80e1 100644 --- a/homeassistant/components/sense.py +++ b/homeassistant/components/sense.py @@ -27,7 +27,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_EMAIL): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_TIMEOUT, DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) }, extra=vol.ALLOW_EXTRA) From ca86755409a26c32dd5eb7ad513d6e6fb99fbc4e Mon Sep 17 00:00:00 2001 From: Adam Belebczuk Date: Sun, 11 Nov 2018 17:16:23 -0500 Subject: [PATCH 129/238] Discord - Minor bugfixes (#18385) Suppress PyNaCl warnings of Discord notify --- homeassistant/components/notify/discord.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index 8bd4e27155d..e9f0d5cec28 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -41,6 +41,8 @@ class DiscordNotificationService(BaseNotificationService): async def async_send_message(self, message, **kwargs): """Login to Discord, send message to channel(s) and log out.""" import discord + + discord.VoiceClient.warn_nacl = False discord_bot = discord.Client(loop=self.hass.loop) if ATTR_TARGET not in kwargs: @@ -53,6 +55,7 @@ class DiscordNotificationService(BaseNotificationService): """Send the messages when the bot is ready.""" try: data = kwargs.get(ATTR_DATA) + images = None if data: images = data.get(ATTR_IMAGES) for channelid in kwargs[ATTR_TARGET]: From 1f06d6ac1a391e41bf18e89c87ef8733e99ddd99 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Mon, 12 Nov 2018 11:09:39 +0100 Subject: [PATCH 130/238] Update waze_travel_time.py (#18399) --- homeassistant/components/sensor/waze_travel_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index 090aff78064..c55c229f549 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -25,7 +25,7 @@ ATTR_DURATION = 'duration' ATTR_DISTANCE = 'distance' ATTR_ROUTE = 'route' -CONF_ATTRIBUTION = "Data provided by the Waze.com" +CONF_ATTRIBUTION = "Powered by Waze" CONF_DESTINATION = 'destination' CONF_ORIGIN = 'origin' CONF_INCL_FILTER = 'incl_filter' From afd9c44ffb9b7cecdf7eaf670f4ec553299ce118 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Mon, 12 Nov 2018 11:10:53 +0100 Subject: [PATCH 131/238] Bumping aioasuswrt (#18400) --- homeassistant/components/asuswrt.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index 37a55238e6b..a5497f9f366 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.4'] +REQUIREMENTS = ['aioasuswrt==1.1.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 459fd365c08..6dcaf2803df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ abodepy==0.14.0 afsapi==0.0.4 # homeassistant.components.asuswrt -aioasuswrt==1.1.4 +aioasuswrt==1.1.6 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From 9eac11dcbecc2fd1d38a55c2195bb14b4d58f053 Mon Sep 17 00:00:00 2001 From: Jorim Tielemans Date: Mon, 12 Nov 2018 11:26:05 +0100 Subject: [PATCH 132/238] Filter Coinbase account wallets (#18167) Only add sensor entities for accounts with the specified currencies. This is a none breaking change. If it's not specified then all account wallets will be loaded. --- homeassistant/components/coinbase.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py index 154320b4abd..98c321b9f5a 100644 --- a/homeassistant/components/coinbase.py +++ b/homeassistant/components/coinbase.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'coinbase' CONF_API_SECRET = 'api_secret' +CONF_ACCOUNT_CURRENCIES = 'account_balance_currencies' CONF_EXCHANGE_CURRENCIES = 'exchange_rate_currencies' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) @@ -31,6 +32,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_API_SECRET): cv.string, + vol.Optional(CONF_ACCOUNT_CURRENCIES): + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]): vol.All(cv.ensure_list, [cv.string]) }) @@ -45,6 +48,7 @@ def setup(hass, config): """ api_key = config[DOMAIN].get(CONF_API_KEY) api_secret = config[DOMAIN].get(CONF_API_SECRET) + account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES) exchange_currencies = config[DOMAIN].get(CONF_EXCHANGE_CURRENCIES) hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData( @@ -53,7 +57,13 @@ def setup(hass, config): if not hasattr(coinbase_data, 'accounts'): return False for account in coinbase_data.accounts.data: - load_platform(hass, 'sensor', DOMAIN, {'account': account}, config) + if (account_currencies is None or + account.currency in account_currencies): + load_platform(hass, + 'sensor', + DOMAIN, + {'account': account}, + config) for currency in exchange_currencies: if currency not in coinbase_data.exchange_rates.rates: _LOGGER.warning("Currency %s not found", currency) From 8fb6030f970474941896a157fd1114d3087c1a1a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 12 Nov 2018 10:18:52 +0100 Subject: [PATCH 133/238] Bump frontend to 20181112.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 2551ce8f6a9..2f723af2b13 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181107.0'] +REQUIREMENTS = ['home-assistant-frontend==20181112.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 6dcaf2803df..418c74cbbdb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -479,7 +479,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181107.0 +home-assistant-frontend==20181112.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fd96db4942..b94a0ac00ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181107.0 +home-assistant-frontend==20181112.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From e5930da972063ae3aa49871064523eafc7978e22 Mon Sep 17 00:00:00 2001 From: Philipp Wensauer Date: Mon, 12 Nov 2018 15:54:08 +0100 Subject: [PATCH 134/238] Update pynello to 2.0.2 (#18402) * Update to pynello 2.0.2 * Update to pynello 2.0.2 * Update to pynello 2.0.2 --- homeassistant/components/lock/nello.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lock/nello.py b/homeassistant/components/lock/nello.py index 4fd9faafcbe..e7eaea8fcd3 100644 --- a/homeassistant/components/lock/nello.py +++ b/homeassistant/components/lock/nello.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME) -REQUIREMENTS = ['pynello==1.5.1'] +REQUIREMENTS = ['pynello==2.0.2'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Nello lock platform.""" - from pynello import Nello + from pynello.private import Nello nello = Nello(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) add_entities([NelloLock(lock) for lock in nello.locations], True) diff --git a/requirements_all.txt b/requirements_all.txt index 418c74cbbdb..1f842754111 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1044,7 +1044,7 @@ pymyq==0.0.15 pymysensors==0.18.0 # homeassistant.components.lock.nello -pynello==1.5.1 +pynello==2.0.2 # homeassistant.components.device_tracker.netgear pynetgear==0.5.1 From 15c77fe54856080d144e854f9adf9281d85a118f Mon Sep 17 00:00:00 2001 From: Levi Govaerts Date: Mon, 12 Nov 2018 15:59:53 +0100 Subject: [PATCH 135/238] Add niko-home-control support (#18019) * Add niko-home-control support * Remove the sensor platform * Minor changes * Fix docstring --- .coveragerc | 1 + .../components/light/niko_home_control.py | 89 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 93 insertions(+) create mode 100644 homeassistant/components/light/niko_home_control.py diff --git a/.coveragerc b/.coveragerc index bd48eab5241..b6273ebc191 100644 --- a/.coveragerc +++ b/.coveragerc @@ -541,6 +541,7 @@ omit = homeassistant/components/light/lw12wifi.py homeassistant/components/light/mystrom.py homeassistant/components/light/nanoleaf_aurora.py + homeassistant/components/light/niko_home_control.py homeassistant/components/light/opple.py homeassistant/components/light/osramlightify.py homeassistant/components/light/piglow.py diff --git a/homeassistant/components/light/niko_home_control.py b/homeassistant/components/light/niko_home_control.py new file mode 100644 index 00000000000..3146954ed62 --- /dev/null +++ b/homeassistant/components/light/niko_home_control.py @@ -0,0 +1,89 @@ +""" +Support for Niko Home Control. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/light.niko_home_control/ +""" +import logging +import socket + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, PLATFORM_SCHEMA, Light) +from homeassistant.const import CONF_HOST +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['niko-home-control==0.1.8'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Niko Home Control light platform.""" + import nikohomecontrol + + host = config.get(CONF_HOST) + + try: + hub = nikohomecontrol.Hub({ + 'ip': host, + 'port': 8000, + 'timeout': 20000, + 'events': True + }) + except socket.error as err: + _LOGGER.error("Unable to access %s (%s)", host, err) + raise PlatformNotReady + + add_devices( + [NikoHomeControlLight(light, hub) for light in hub.list_actions()], + True) + + +class NikoHomeControlLight(Light): + """Representation of an Niko Light.""" + + def __init__(self, light, nhc): + """Set up the Niko Home Control light platform.""" + self._nhc = nhc + self._light = light + self._name = light.name + self._state = None + self._brightness = None + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + self._light.brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + self._light.turn_on() + self._state = True + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.turn_off() + self._state = False + + def update(self): + """Fetch new state data for this light.""" + self._light.update() + self._state = self._light.is_on diff --git a/requirements_all.txt b/requirements_all.txt index 1f842754111..c00eb43d330 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -667,6 +667,9 @@ netdisco==2.2.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 +# homeassistant.components.light.niko_home_control +niko-home-control==0.1.8 + # homeassistant.components.sensor.nederlandse_spoorwegen nsapi==2.7.4 From 275b485b360ad026f2a8d44d49f5a57ade528ddd Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Mon, 12 Nov 2018 10:10:28 -0500 Subject: [PATCH 136/238] Add support for keypad keypress (#18393) * Add support for keypad keypress * Update requirements_all --- homeassistant/components/elkm1/__init__.py | 2 +- homeassistant/components/sensor/elkm1.py | 1 + requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 755696b5002..19f19620e8c 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType # noqa DOMAIN = "elkm1" -REQUIREMENTS = ['elkm1-lib==0.7.10'] +REQUIREMENTS = ['elkm1-lib==0.7.11'] CONF_AREA = 'area' CONF_COUNTER = 'counter' diff --git a/homeassistant/components/sensor/elkm1.py b/homeassistant/components/sensor/elkm1.py index 288f968b2f7..3fd57b190a6 100644 --- a/homeassistant/components/sensor/elkm1.py +++ b/homeassistant/components/sensor/elkm1.py @@ -91,6 +91,7 @@ class ElkKeypad(ElkSensor): attrs['last_user'] = self._element.last_user + 1 attrs['code'] = self._element.code attrs['last_user_name'] = username(self._elk, self._element.last_user) + attrs['last_keypress'] = self._element.last_keypress return attrs def _element_changed(self, element, changeset): diff --git a/requirements_all.txt b/requirements_all.txt index c00eb43d330..e7ce9028540 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -333,7 +333,7 @@ einder==0.3.1 eliqonline==1.0.14 # homeassistant.components.elkm1 -elkm1-lib==0.7.10 +elkm1-lib==0.7.11 # homeassistant.components.enocean enocean==0.40 From d0463942be60fb44370d3ee5ef75e89e9bd0f16f Mon Sep 17 00:00:00 2001 From: Pawel Date: Mon, 12 Nov 2018 20:46:00 +0100 Subject: [PATCH 137/238] Changed checking of cover state closed from 0 to closed_position variable. (#18407) Change error message to avoid expression "get_position_topic". --- homeassistant/components/cover/mqtt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 8cc80c52bc5..235b28b5be2 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -87,7 +87,7 @@ def validate_options(value): if (CONF_SET_POSITION_TOPIC in value and CONF_GET_POSITION_TOPIC not in value): raise vol.Invalid( - "Set position topic must be set together with get position topic.") + "set_position_topic must be set together with position_topic.") return value @@ -287,7 +287,7 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, float(payload), COVER_PAYLOAD) if 0 <= percentage_payload <= 100: self._position = percentage_payload - self._state = self._position == 0 + self._state = self._position == self._position_closed else: _LOGGER.warning( "Payload is not integer within range: %s", @@ -451,7 +451,7 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, mqtt.async_publish(self.hass, self._set_position_topic, position, self._qos, self._retain) if self._optimistic: - self._state = percentage_position == 0 + self._state = percentage_position == self._position_closed self._position = percentage_position self.async_schedule_update_ha_state() From 0ab81b03a85b6b164f5b8b0225744d08d9eec1ab Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 12 Nov 2018 20:28:00 +0000 Subject: [PATCH 138/238] cancel off_delay action (#18389) --- homeassistant/components/binary_sensor/mqtt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index db9ad585999..7f164ae48d7 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -155,6 +155,7 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, if self._delay_listener is not None: self._delay_listener() + self._delay_listener = None if (self._state and self._off_delay is not None): self._delay_listener = evt.async_call_later( From ea4480f170065dd89b4f963c5c69864aa7cfa631 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 12 Nov 2018 22:32:30 +0100 Subject: [PATCH 139/238] Use existing constant (#18408) --- homeassistant/components/weather/ecobee.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/weather/ecobee.py b/homeassistant/components/weather/ecobee.py index 5a191aa7af1..7382e5c1815 100644 --- a/homeassistant/components/weather/ecobee.py +++ b/homeassistant/components/weather/ecobee.py @@ -5,19 +5,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/weather.ecobee/ """ from datetime import datetime + from homeassistant.components import ecobee from homeassistant.components.weather import ( - WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME) -from homeassistant.const import (TEMP_FAHRENHEIT) - + ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_SPEED, WeatherEntity) +from homeassistant.const import TEMP_FAHRENHEIT DEPENDENCIES = ['ecobee'] ATTR_FORECAST_TEMP_HIGH = 'temphigh' ATTR_FORECAST_PRESSURE = 'pressure' ATTR_FORECAST_VISIBILITY = 'visibility' -ATTR_FORECAST_WIND_SPEED = 'windspeed' ATTR_FORECAST_HUMIDITY = 'humidity' MISSING_DATA = -5002 @@ -41,7 +40,7 @@ class EcobeeWeather(WeatherEntity): """Representation of Ecobee weather data.""" def __init__(self, name, index): - """Initialize the sensor.""" + """Initialize the Ecobee weather platform.""" self._name = name self._index = index self.weather = None From c6683cba7d7541be41c8ba4efc408a2452b7bca7 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 12 Nov 2018 22:53:50 +0100 Subject: [PATCH 140/238] updated pydaikin version (#18413) --- homeassistant/components/climate/daikin.py | 2 +- homeassistant/components/daikin.py | 8 ++------ requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 2d4e01aaee9..63b8f585c7e 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pydaikin==0.4'] +REQUIREMENTS = ['pydaikin==0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py index 8983ecf82d8..20da244a698 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -19,12 +19,11 @@ from homeassistant.helpers import discovery from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = ['pydaikin==0.4'] +REQUIREMENTS = ['pydaikin==0.6'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'daikin' -HTTP_RESOURCES = ['aircon/get_sensor_info', 'aircon/get_control_info'] ATTR_TARGET_TEMPERATURE = 'target_temperature' ATTR_INSIDE_TEMPERATURE = 'inside_temperature' @@ -128,10 +127,7 @@ class DaikinApi: def update(self, **kwargs): """Pull the latest data from Daikin.""" try: - for resource in HTTP_RESOURCES: - self.device.values.update( - self.device.get_resource(resource) - ) + self.device.update_status() except timeout: _LOGGER.warning( "Connection failed for %s", self.ip_address diff --git a/requirements_all.txt b/requirements_all.txt index e7ce9028540..b2600027d91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -880,7 +880,7 @@ pycsspeechtts==1.0.2 # homeassistant.components.daikin # homeassistant.components.climate.daikin -pydaikin==0.4 +pydaikin==0.6 # homeassistant.components.deconz pydeconz==47 From 85474890145f2808474529e5c653bc37b22d6154 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 12 Nov 2018 19:10:03 -0700 Subject: [PATCH 141/238] Bumps pytile to 2.0.5 (#18395) --- homeassistant/components/device_tracker/tile.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index 224aee4363b..81d8a6867c6 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -18,7 +18,7 @@ from homeassistant.util import slugify from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pytile==2.0.2'] +REQUIREMENTS = ['pytile==2.0.5'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' DEVICE_TYPES = ['PHONE', 'TILE'] diff --git a/requirements_all.txt b/requirements_all.txt index b2600027d91..549b50f330e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1264,7 +1264,7 @@ pythonegardia==1.0.39 pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile -pytile==2.0.2 +pytile==2.0.5 # homeassistant.components.climate.touchline pytouchline==0.7 From 996da72a4c5a0c1d71a68b153d17323074c706dd Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 13 Nov 2018 08:33:34 +0100 Subject: [PATCH 142/238] Daikin fixes (#18415) * updated pydaikin version * some Daikin models does not support fan settings --- homeassistant/components/climate/daikin.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 63b8f585c7e..ab524dc389d 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -105,16 +105,10 @@ class DaikinClimate(ClimateDevice): daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE] if self._api.device.values.get(daikin_attr) is not None: self._supported_features |= SUPPORT_FAN_MODE - else: - # even devices without support must have a default valid value - self._api.device.values[daikin_attr] = 'A' daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE] if self._api.device.values.get(daikin_attr) is not None: self._supported_features |= SUPPORT_SWING_MODE - else: - # even devices without support must have a default valid value - self._api.device.values[daikin_attr] = '0' def get(self, key): """Retrieve device settings from API library cache.""" From f78dcb96b08f5117cfbc10324e7f8e328589bbd4 Mon Sep 17 00:00:00 2001 From: kbickar Date: Tue, 13 Nov 2018 02:34:11 -0500 Subject: [PATCH 143/238] Sense will not list removed devices (#18410) * Sense will not list removed devices * quote update --- homeassistant/components/binary_sensor/sense.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/sense.py b/homeassistant/components/binary_sensor/sense.py index 8c5ddda0383..1f83bffdcb6 100644 --- a/homeassistant/components/binary_sensor/sense.py +++ b/homeassistant/components/binary_sensor/sense.py @@ -60,7 +60,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = hass.data[SENSE_DATA] sense_devices = data.get_discovered_device_data() - devices = [SenseDevice(data, device) for device in sense_devices] + devices = [SenseDevice(data, device) for device in sense_devices + if device['tags']['DeviceListAllowed'] == 'true'] add_entities(devices) From 7113ec6073094a59d4e30b79d524643f14e3e753 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 13 Nov 2018 10:01:14 +0100 Subject: [PATCH 144/238] Fix smhi docstrings (#18414) * Fix docstrings * Fix lint * Fix another typo * Fix mobile phone edit --- homeassistant/components/smhi/__init__.py | 17 +++-- homeassistant/components/smhi/config_flow.py | 32 +++------- homeassistant/components/smhi/const.py | 10 ++- homeassistant/components/weather/smhi.py | 66 +++++++++----------- 4 files changed, 51 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 2421addfd0c..0ca3bac3e35 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -1,5 +1,5 @@ """ -Component for the swedish weather institute weather service. +Component for the Swedish weather institute weather service. For more details about this component, please refer to the documentation at https://home-assistant.io/components/smhi/ @@ -7,8 +7,7 @@ https://home-assistant.io/components/smhi/ from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant -# Have to import for config_flow to work -# even if they are not used here +# Have to import for config_flow to work even if they are not used here from .config_flow import smhi_locations # noqa: F401 from .const import DOMAIN # noqa: F401 @@ -18,21 +17,21 @@ DEFAULT_NAME = 'smhi' async def async_setup(hass: HomeAssistant, config: Config) -> bool: - """Set up configured smhi.""" + """Set up configured SMHI.""" # We allow setup only through config flow type of config return True -async def async_setup_entry(hass: HomeAssistant, - config_entry: ConfigEntry) -> bool: - """Set up smhi forecast as config entry.""" +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up SMHI forecast as config entry.""" hass.async_create_task(hass.config_entries.async_forward_entry_setup( config_entry, 'weather')) return True -async def async_unload_entry(hass: HomeAssistant, - config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" await hass.config_entries.async_forward_entry_unload( config_entry, 'weather') diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index e461c6d195d..e1ebf81bac7 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -1,21 +1,11 @@ -"""Config flow to configure smhi component. - -First time the user creates the configuration and -a valid location is set in the hass configuration yaml -it will use that location and use it as default values. - -Additional locations can be added in config form. -The input location will be checked by invoking -the API. Exception will be thrown if the location -is not supported by the API (Swedish locations only) -""" +"""Config flow to configure SMHI component.""" import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant import config_entries, data_entry_flow -from homeassistant.const import (CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from .const import DOMAIN, HOME_LOCATION_NAME @@ -45,9 +35,7 @@ class SmhiFlowHandler(data_entry_flow.FlowHandler): if user_input is not None: is_ok = await self._check_location( - user_input[CONF_LONGITUDE], - user_input[CONF_LATITUDE] - ) + user_input[CONF_LONGITUDE], user_input[CONF_LATITUDE]) if is_ok: name = slugify(user_input[CONF_NAME]) if not self._name_in_configuration_exists(name): @@ -60,9 +48,8 @@ class SmhiFlowHandler(data_entry_flow.FlowHandler): else: self._errors['base'] = 'wrong_location' - # If hass config has the location set and - # is a valid coordinate the default location - # is set as default values in the form + # If hass config has the location set and is a valid coordinate the + # default location is set as default values in the form if not smhi_locations(self.hass): if await self._homeassistant_location_exists(): return await self._show_config_form( @@ -79,8 +66,7 @@ class SmhiFlowHandler(data_entry_flow.FlowHandler): self.hass.config.longitude != 0.0: # Return true if valid location if await self._check_location( - self.hass.config.longitude, - self.hass.config.latitude): + self.hass.config.longitude, self.hass.config.latitude): return True return False @@ -90,9 +76,7 @@ class SmhiFlowHandler(data_entry_flow.FlowHandler): return True return False - async def _show_config_form(self, - name: str = None, - latitude: str = None, + async def _show_config_form(self, name: str = None, latitude: str = None, longitude: str = None): """Show the configuration form to edit location data.""" return self.async_show_form( diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py index 49e0f295873..9689857e546 100644 --- a/homeassistant/components/smhi/const.py +++ b/homeassistant/components/smhi/const.py @@ -1,12 +1,16 @@ """Constants in smhi component.""" import logging + from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +ATTR_SMHI_CLOUDINESS = 'cloudiness' + +DOMAIN = 'smhi' + HOME_LOCATION_NAME = 'Home' -ATTR_SMHI_CLOUDINESS = 'cloudiness' -DOMAIN = 'smhi' -LOGGER = logging.getLogger('homeassistant.components.smhi') ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}" ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( HOME_LOCATION_NAME) + +LOGGER = logging.getLogger('homeassistant.components.smhi') diff --git a/homeassistant/components/weather/smhi.py b/homeassistant/components/weather/smhi.py index 41ac1571339..c686b5c90e9 100644 --- a/homeassistant/components/weather/smhi.py +++ b/homeassistant/components/weather/smhi.py @@ -1,33 +1,28 @@ -"""Support for the Swedish weather institute weather service. +""" +Support for the Swedish weather institute weather service. For more details about this platform, please refer to the documentation https://home-assistant.io/components/weather.smhi/ """ - - import asyncio -import logging from datetime import timedelta +import logging from typing import Dict, List import aiohttp import async_timeout +from homeassistant.components.smhi.const import ( + ATTR_SMHI_CLOUDINESS, ENTITY_ID_SENSOR_FORMAT) +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, WeatherEntity) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, TEMP_CELSIUS) + CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.util import dt, slugify, Throttle - -from homeassistant.components.weather import ( - WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, - ATTR_FORECAST_PRECIPITATION) - -from homeassistant.components.smhi.const import ( - ENTITY_ID_SENSOR_FORMAT, ATTR_SMHI_CLOUDINESS) +from homeassistant.util import Throttle, dt, slugify DEPENDENCIES = ['smhi'] @@ -51,15 +46,14 @@ CONDITION_CLASSES = { 'exceptional': [], } - # 5 minutes between retrying connect to API again RETRY_TIMEOUT = 5*60 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): """Old way of setting up components. Can only be called when a user accidentally mentions smhi in the @@ -68,18 +62,18 @@ async def async_setup_platform(hass, config, async_add_entities, pass -async def async_setup_entry(hass: HomeAssistant, - config_entry: ConfigEntry, - config_entries) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, + config_entries) -> bool: """Add a weather entity from map location.""" location = config_entry.data name = slugify(location[CONF_NAME]) session = aiohttp_client.async_get_clientsession(hass) - entity = SmhiWeather(location[CONF_NAME], location[CONF_LATITUDE], - location[CONF_LONGITUDE], - session=session) + entity = SmhiWeather( + location[CONF_NAME], location[CONF_LATITUDE], location[CONF_LONGITUDE], + session=session) entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) config_entries([entity], True) @@ -100,8 +94,7 @@ class SmhiWeather(WeatherEntity): self._longitude = longitude self._forecasts = None self._fail_count = 0 - self._smhi_api = Smhi(self._longitude, self._latitude, - session=session) + self._smhi_api = Smhi(self._longitude, self._latitude, session=session) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: @@ -109,6 +102,7 @@ class SmhiWeather(WeatherEntity): from smhi.smhi_lib import SmhiForecastException def fail(): + """Postpone updates.""" self._fail_count += 1 if self._fail_count < 3: self.hass.helpers.event.async_call_later( @@ -120,8 +114,8 @@ class SmhiWeather(WeatherEntity): self._fail_count = 0 except (asyncio.TimeoutError, SmhiForecastException): - _LOGGER.error("Failed to connect to SMHI API, " - "retry in 5 minutes") + _LOGGER.error( + "Failed to connect to SMHI API, retry in 5 minutes") fail() async def retry_update(self): @@ -161,7 +155,7 @@ class SmhiWeather(WeatherEntity): """Return the wind speed.""" if self._forecasts is not None: # Convert from m/s to km/h - return round(self._forecasts[0].wind_speed*18/5) + return round(self._forecasts[0].wind_speed * 18 / 5) return None @property @@ -221,17 +215,13 @@ class SmhiWeather(WeatherEntity): # Only get mid day forecasts if forecast.valid_time.hour == 12: data.append({ - ATTR_FORECAST_TIME: - dt.as_local(forecast.valid_time), - ATTR_FORECAST_TEMP: - forecast.temperature_max, - ATTR_FORECAST_TEMP_LOW: - forecast.temperature_min, + ATTR_FORECAST_TIME: dt.as_local(forecast.valid_time), + ATTR_FORECAST_TEMP: forecast.temperature_max, + ATTR_FORECAST_TEMP_LOW: forecast.temperature_min, ATTR_FORECAST_PRECIPITATION: round(forecast.mean_precipitation*24), - ATTR_FORECAST_CONDITION: - condition - }) + ATTR_FORECAST_CONDITION: condition, + }) return data From 7aa41d66e9e1c3088a0f77072dc7cd978d239f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 13 Nov 2018 11:29:04 +0100 Subject: [PATCH 145/238] Avg price for tibber sensor (#18426) * Avg price for tibber sensor * change to sum_price --- homeassistant/components/sensor/tibber.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 861fd6eff53..703f2bbbd17 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -132,6 +132,8 @@ class TibberSensorElPrice(Entity): state = None max_price = 0 min_price = 10000 + sum_price = 0 + num = 0 now = dt_util.now() for key, price_total in self._tibber_home.price_total.items(): price_time = dt_util.as_local(dt_util.parse_datetime(key)) @@ -146,8 +148,11 @@ class TibberSensorElPrice(Entity): if now.date() == price_time.date(): max_price = max(max_price, price_total) min_price = min(min_price, price_total) + num += 1 + sum_price += price_total self._state = state self._device_state_attributes['max_price'] = max_price + self._device_state_attributes['avg_price'] = sum_price / num self._device_state_attributes['min_price'] = min_price return state is not None From ebdfb568036231d490e54a0751cbf0dfdaaf7e0b Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 13 Nov 2018 14:04:36 +0100 Subject: [PATCH 146/238] Bumping aioasuswrt (#18427) --- homeassistant/components/asuswrt.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index a5497f9f366..c653c1d03fd 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.6'] +REQUIREMENTS = ['aioasuswrt==1.1.11'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 549b50f330e..747a5f17679 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ abodepy==0.14.0 afsapi==0.0.4 # homeassistant.components.asuswrt -aioasuswrt==1.1.6 +aioasuswrt==1.1.11 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From f14251bdcc42121b872f1ae07cf14a8168b8b534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 13 Nov 2018 13:24:30 +0000 Subject: [PATCH 147/238] edp_redy: increase UPDATE_INTERVAL (#18429) The server was getting a bit mad sometimes and would lock users out. --- homeassistant/components/edp_redy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/edp_redy.py b/homeassistant/components/edp_redy.py index caf4ad41d99..210d7eb6afc 100644 --- a/homeassistant/components/edp_redy.py +++ b/homeassistant/components/edp_redy.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'edp_redy' EDP_REDY = 'edp_redy' DATA_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) -UPDATE_INTERVAL = 30 +UPDATE_INTERVAL = 60 REQUIREMENTS = ['edp_redy==0.0.2'] From 597da9062209855b36bc615a480589c61d5e1aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 13 Nov 2018 21:00:21 +0100 Subject: [PATCH 148/238] Fixes issue for returning the correct docker version. (#18439) --- homeassistant/components/sensor/version.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/version.py b/homeassistant/components/sensor/version.py index afedd61326c..8a2a7593b2c 100644 --- a/homeassistant/components/sensor/version.py +++ b/homeassistant/components/sensor/version.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_NAME, CONF_SOURCE from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyhaversion==2.0.2'] +REQUIREMENTS = ['pyhaversion==2.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 747a5f17679..798fe8edc70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -950,7 +950,7 @@ pygtfs-homeassistant==0.1.3.dev0 pyharmony==1.0.20 # homeassistant.components.sensor.version -pyhaversion==2.0.2 +pyhaversion==2.0.3 # homeassistant.components.binary_sensor.hikvision pyhik==0.1.8 From 291fba0ba4ecd610ec6a4bd27e74b103490e3ca6 Mon Sep 17 00:00:00 2001 From: JC Connell Date: Tue, 13 Nov 2018 17:41:58 -0500 Subject: [PATCH 149/238] Update Magicseaweed sensor (#18446) * Increment python-magicseaweed version. * Update requirements_all.txt --- homeassistant/components/sensor/magicseaweed.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/magicseaweed.py b/homeassistant/components/sensor/magicseaweed.py index 59f38553d79..e14af6c3392 100644 --- a/homeassistant/components/sensor/magicseaweed.py +++ b/homeassistant/components/sensor/magicseaweed.py @@ -16,7 +16,7 @@ import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['magicseaweed==1.0.0'] +REQUIREMENTS = ['magicseaweed==1.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 798fe8edc70..5015b806530 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -606,7 +606,7 @@ lw12==0.9.2 lyft_rides==0.2 # homeassistant.components.sensor.magicseaweed -magicseaweed==1.0.0 +magicseaweed==1.0.3 # homeassistant.components.matrix matrix-client==0.2.0 From 532a75b487251edc2f86fa1a05e221fd1bd47608 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 13 Nov 2018 23:43:01 +0100 Subject: [PATCH 150/238] Update pyozw to 0.1.1 (#18436) * Update pyozw to 0.1.1 * Update requirements_all.txt --- homeassistant/components/zwave/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index a27d2112dcd..87a955f6f20 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -42,7 +42,7 @@ from .discovery_schemas import DISCOVERY_SCHEMAS from .util import (check_node_schema, check_value_schema, node_name, check_has_unique_id, is_node_parsed) -REQUIREMENTS = ['pydispatcher==2.0.5', 'homeassistant-pyozw==0.1.0'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'homeassistant-pyozw==0.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 5015b806530..4e1b7b1ca7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -482,7 +482,7 @@ holidays==0.9.8 home-assistant-frontend==20181112.0 # homeassistant.components.zwave -homeassistant-pyozw==0.1.0 +homeassistant-pyozw==0.1.1 # homeassistant.components.homekit_controller # homekit==0.10 From 419400f90b80b61bf2762b27341ca06597338561 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Tue, 13 Nov 2018 20:20:15 -0500 Subject: [PATCH 151/238] Bump elkm1_lib version (#18450) --- homeassistant/components/elkm1/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 19f19620e8c..94248000601 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType # noqa DOMAIN = "elkm1" -REQUIREMENTS = ['elkm1-lib==0.7.11'] +REQUIREMENTS = ['elkm1-lib==0.7.12'] CONF_AREA = 'area' CONF_COUNTER = 'counter' diff --git a/requirements_all.txt b/requirements_all.txt index 4e1b7b1ca7b..c41cd333b42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -333,7 +333,7 @@ einder==0.3.1 eliqonline==1.0.14 # homeassistant.components.elkm1 -elkm1-lib==0.7.11 +elkm1-lib==0.7.12 # homeassistant.components.enocean enocean==0.40 From d2907b8e535af20e1a3c2307c85b7e96e74f882d Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Wed, 14 Nov 2018 12:33:27 +0100 Subject: [PATCH 152/238] Add Philips Zhirui Downlight support (#18455) --- homeassistant/components/light/xiaomi_miio.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index 291d8eaa267..f2e8e120d53 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -42,6 +42,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'philips.light.candle', 'philips.light.candle2', 'philips.light.mono1', + 'philips.light.downlight', ]), }) @@ -152,7 +153,8 @@ async def async_setup_platform(hass, config, async_add_entities, hass.data[DATA_KEY][host] = device elif model in ['philips.light.bulb', 'philips.light.candle', - 'philips.light.candle2']: + 'philips.light.candle2', + 'philips.light.downlight']: from miio import PhilipsBulb light = PhilipsBulb(host, token) device = XiaomiPhilipsBulb(name, light, model, unique_id) From d2e102ee2f82414da64122a317c0611412edf399 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Wed, 14 Nov 2018 07:13:32 -0700 Subject: [PATCH 153/238] Init statistics sensor upon HASS start (#18236) * Update query to include maxAge Updated the query from recorded to include MaxAge if set; reducing the amount of records retrieved that would otherwise be purged anyway for the sensor. * Initialization upon HASS start Register the state listener and read previous information from recorder once HASS is started. * Updated test_statistics.py for HASS start Updated test_statistics.py to start HASS and wait it is completed before running test. * Added newline in docstring Added newline in docstring. * Added start of HASS to test_initialize_from_database_with_maxage Added start of HASS to new test test_initialize_from_database_with_maxage. * Updates based on review Following updates based on review: -) Removed self._hass and passing hass -) Changed async_add_job to async_create_task -) For state update, calling async_schedule_update_ha_state --- homeassistant/components/sensor/statistics.py | 40 +++++++++++++------ tests/components/sensor/test_statistics.py | 25 ++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 26253abd484..e7a35b5fdf0 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -13,7 +13,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_ENTITY_ID, STATE_UNKNOWN, ATTR_UNIT_OF_MEASUREMENT) + CONF_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, + ATTR_UNIT_OF_MEASUREMENT) from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change @@ -66,7 +67,7 @@ async def async_setup_platform(hass, config, async_add_entities, max_age = config.get(CONF_MAX_AGE, None) precision = config.get(CONF_PRECISION) - async_add_entities([StatisticsSensor(hass, entity_id, name, sampling_size, + async_add_entities([StatisticsSensor(entity_id, name, sampling_size, max_age, precision)], True) return True @@ -75,10 +76,9 @@ async def async_setup_platform(hass, config, async_add_entities, class StatisticsSensor(Entity): """Representation of a Statistics sensor.""" - def __init__(self, hass, entity_id, name, sampling_size, max_age, + def __init__(self, entity_id, name, sampling_size, max_age, precision): """Initialize the Statistics sensor.""" - self._hass = hass self._entity_id = entity_id self.is_binary = True if self._entity_id.split('.')[0] == \ 'binary_sensor' else False @@ -99,10 +99,8 @@ class StatisticsSensor(Entity): self.min_age = self.max_age = None self.change = self.average_change = self.change_rate = None - if 'recorder' in self._hass.config.components: - # only use the database if it's configured - hass.async_add_job(self._initialize_from_database) - + async def async_added_to_hass(self): + """Register callbacks.""" @callback def async_stats_sensor_state_listener(entity, old_state, new_state): """Handle the sensor state changes.""" @@ -111,10 +109,24 @@ class StatisticsSensor(Entity): self._add_state_to_queue(new_state) - hass.async_add_job(self.async_update_ha_state, True) + self.async_schedule_update_ha_state(True) - async_track_state_change( - hass, entity_id, async_stats_sensor_state_listener) + @callback + def async_stats_sensor_startup(event): + """Add listener and get recorded state.""" + _LOGGER.debug("Startup for %s", self.entity_id) + + async_track_state_change( + self.hass, self._entity_id, async_stats_sensor_state_listener) + + if 'recorder' in self.hass.config.components: + # only use the database if it's configured + self.hass.async_create_task( + self._async_initialize_from_database() + ) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_stats_sensor_startup) def _add_state_to_queue(self, new_state): try: @@ -241,7 +253,7 @@ class StatisticsSensor(Entity): self.change = self.average_change = STATE_UNKNOWN self.change_rate = STATE_UNKNOWN - async def _initialize_from_database(self): + async def _async_initialize_from_database(self): """Initialize the list of states from the database. The query will get the list of states in DESCENDING order so that we @@ -255,7 +267,7 @@ class StatisticsSensor(Entity): _LOGGER.debug("%s: initializing values from the database", self.entity_id) - with session_scope(hass=self._hass) as session: + with session_scope(hass=self.hass) as session: query = session.query(States)\ .filter(States.entity_id == self._entity_id.lower()) @@ -275,5 +287,7 @@ class StatisticsSensor(Entity): for state in reversed(states): self._add_state_to_queue(state) + self.async_schedule_update_ha_state(True) + _LOGGER.debug("%s: initializing from database completed", self.entity_id) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 9188513b861..5d1137c35e6 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -49,6 +49,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in values: self.hass.states.set('binary_sensor.test_monitored', value) self.hass.block_till_done() @@ -67,6 +70,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values: self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -100,6 +106,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values: self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -121,6 +130,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values[-3:]: # just the last 3 will do self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -162,6 +174,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values: self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -194,6 +209,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values: self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -231,6 +249,10 @@ class TestStatisticsSensor(unittest.TestCase): 'sampling_size': 100, } }) + + self.hass.start() + self.hass.block_till_done() + # check if the result is as in test_sensor_source() state = self.hass.states.get('sensor.test_mean') assert str(self.mean) == state.state @@ -284,6 +306,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + # check if the result is as in test_sensor_source() state = self.hass.states.get('sensor.test_mean') From 0d43cb6d0e67e4fead9d0080a1f98f7af1bbbe00 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 14 Nov 2018 15:38:16 +0100 Subject: [PATCH 154/238] fixes for last version bump on pydaikin (#18438) --- homeassistant/components/climate/daikin.py | 7 ++----- homeassistant/components/daikin.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index ab524dc389d..99e2d23de29 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pydaikin==0.6'] +REQUIREMENTS = ['pydaikin==0.7'] _LOGGER = logging.getLogger(__name__) @@ -82,7 +82,6 @@ class DaikinClimate(ClimateDevice): from pydaikin import appliance self._api = api - self._force_refresh = False self._list = { ATTR_OPERATION_MODE: list(HA_STATE_TO_DAIKIN), ATTR_FAN_MODE: list( @@ -183,7 +182,6 @@ class DaikinClimate(ClimateDevice): _LOGGER.error("Invalid temperature %s", value) if values: - self._force_refresh = True self._api.device.set(values) @property @@ -264,5 +262,4 @@ class DaikinClimate(ClimateDevice): def update(self): """Retrieve latest state.""" - self._api.update(no_throttle=self._force_refresh) - self._force_refresh = False + self._api.update() diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py index 20da244a698..9ec5a8978f9 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -19,7 +19,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = ['pydaikin==0.6'] +REQUIREMENTS = ['pydaikin==0.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c41cd333b42..5f9fd0d7c21 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -880,7 +880,7 @@ pycsspeechtts==1.0.2 # homeassistant.components.daikin # homeassistant.components.climate.daikin -pydaikin==0.6 +pydaikin==0.7 # homeassistant.components.deconz pydeconz==47 From c7ac21660266233b4f5ceaea6d6e008de9040b37 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Wed, 14 Nov 2018 15:40:43 +0100 Subject: [PATCH 155/238] Readded climate.velbus (#18434) * Readded climate.velbus * houndci-bot changes * Added more comments * Fix flake8 error * return TEMP_* constants * more comments --- homeassistant/components/climate/velbus.py | 74 ++++++++++++++++++++++ homeassistant/components/velbus.py | 3 + 2 files changed, 77 insertions(+) create mode 100644 homeassistant/components/climate/velbus.py diff --git a/homeassistant/components/climate/velbus.py b/homeassistant/components/climate/velbus.py new file mode 100644 index 00000000000..0b0205acefb --- /dev/null +++ b/homeassistant/components/climate/velbus.py @@ -0,0 +1,74 @@ +""" +Support for Velbus thermostat. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.velbus/ +""" +import logging + +from homeassistant.components.climate import ( + STATE_HEAT, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +from homeassistant.components.velbus import ( + DOMAIN as VELBUS_DOMAIN, VelbusEntity) +from homeassistant.const import ( + TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['velbus'] + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the Velbus thermostat platform.""" + if discovery_info is None: + return + + sensors = [] + for sensor in discovery_info: + module = hass.data[VELBUS_DOMAIN].get_module(sensor[0]) + channel = sensor[1] + sensors.append(VelbusClimate(module, channel)) + + async_add_entities(sensors) + + +class VelbusClimate(VelbusEntity, ClimateDevice): + """Representation of a Velbus thermostat.""" + + @property + def supported_features(self): + """Return the list off supported features.""" + return SUPPORT_FLAGS + + @property + def temperature_unit(self): + """Return the unit this state is expressed in.""" + if self._module.get_unit(self._channel) == '°C': + return TEMP_CELSIUS + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._module.get_state(self._channel) + + @property + def current_operation(self): + """Return current operation.""" + return STATE_HEAT + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._module.get_climate_target() + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is None: + return + self._module.set_temp(temp) + self.schedule_update_ha_state() diff --git a/homeassistant/components/velbus.py b/homeassistant/components/velbus.py index a7b385297a8..15ca8584a4e 100644 --- a/homeassistant/components/velbus.py +++ b/homeassistant/components/velbus.py @@ -48,6 +48,7 @@ async def async_setup(hass, config): discovery_info = { 'switch': [], 'binary_sensor': [], + 'climate': [], 'sensor': [] } for module in modules: @@ -60,6 +61,8 @@ async def async_setup(hass, config): )) load_platform(hass, 'switch', DOMAIN, discovery_info['switch'], config) + load_platform(hass, 'climate', DOMAIN, + discovery_info['climate'], config) load_platform(hass, 'binary_sensor', DOMAIN, discovery_info['binary_sensor'], config) load_platform(hass, 'sensor', DOMAIN, From 80e616cacf358ccc9b0b05d76fc4c626bcefca3a Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Wed, 14 Nov 2018 18:05:29 +0100 Subject: [PATCH 156/238] Make mikrotik method setting optional as intended (#18454) Pull request #17852 accidently made the `method` setting required. --- homeassistant/components/device_tracker/mikrotik.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index a4a15fb9d60..d6b965cbadf 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -46,7 +46,7 @@ class MikrotikScanner(DeviceScanner): self.port = config[CONF_PORT] self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] - self.method = config[CONF_METHOD] + self.method = config.get(CONF_METHOD) self.connected = False self.success_init = False From 00235cf6f0a2fc80bcc35ea2461927ae2b114a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 14 Nov 2018 19:35:12 +0100 Subject: [PATCH 157/238] Improve support for 1. generation mill heater (#18423) * Improve support for 1. gen mill heater * style * None operation_list for gen 1 heater * Remove SUPPORT_OPERATION_MODE for gen 1 --- homeassistant/components/climate/mill.py | 15 ++++++++++----- requirements_all.txt | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index f4615ee6517..bfeaba001b8 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.3'] +REQUIREMENTS = ['millheater==0.2.4'] _LOGGER = logging.getLogger(__name__) @@ -32,8 +32,7 @@ MIN_TEMP = 5 SERVICE_SET_ROOM_TEMP = 'mill_set_room_temperature' SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | - SUPPORT_FAN_MODE | SUPPORT_ON_OFF | - SUPPORT_OPERATION_MODE) + SUPPORT_FAN_MODE) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, @@ -92,7 +91,9 @@ class MillHeater(ClimateDevice): @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + if self._heater.is_gen1: + return SUPPORT_FLAGS + return SUPPORT_FLAGS | SUPPORT_ON_OFF | SUPPORT_OPERATION_MODE @property def available(self): @@ -157,6 +158,8 @@ class MillHeater(ClimateDevice): @property def is_on(self): """Return true if heater is on.""" + if self._heater.is_gen1: + return True return self._heater.power_status == 1 @property @@ -177,6 +180,8 @@ class MillHeater(ClimateDevice): @property def operation_list(self): """List of available operation modes.""" + if self._heater.is_gen1: + return None return [STATE_HEAT, STATE_OFF] async def async_set_temperature(self, **kwargs): @@ -211,7 +216,7 @@ class MillHeater(ClimateDevice): """Set operation mode.""" if operation_mode == STATE_HEAT: await self.async_turn_on() - elif operation_mode == STATE_OFF: + elif operation_mode == STATE_OFF and not self._heater.is_gen1: await self.async_turn_off() else: _LOGGER.error("Unrecognized operation mode: %s", operation_mode) diff --git a/requirements_all.txt b/requirements_all.txt index 5f9fd0d7c21..1a880b5d7d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -628,7 +628,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.3 +millheater==0.2.4 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 From 312872961fe40f4384b996c2832801dcad3bfbb1 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Wed, 14 Nov 2018 20:58:32 +0100 Subject: [PATCH 158/238] Initial support for Fibaro HomeCenter hubs (#17891) * Fibaro HC connection, initial commit Very first steps working, connects, fetches devices, represents sensors, binary_sensors and lights towards HA. * Cover, switch, bugfixes Initial support for covers Initial support for switches Bugfixes * Some cleanup and improved lights pylint based cleanup light switches handled properly light features reported correctly * Added status updates and actions Lights, Blinds, Switches are mostly working now * Code cleanup, fiblary3 req Fiblary3 is now in pypi, set it as req Cleanup based on pylint * Included in .coveragerc and added how to use guide Included the fibaro component in coveragerc Added usage instructions to file header * PyLint inspired fixes Fixed pylint warnings * PyLint inspired fixes PyLint inspired fixes * updated to fiblary3 0.1.5 * Minor fixes to finally pass pull req Fixed fiblary3 to work with python 3.5 Updated fiblary3 to 0.1.6 (added energy and batteryLevel dummies) * module import and flake8 fixes Finally (hopefully) figured out what lint is complaining about * Fixed color support for lights, simplified callback Fixed color support for lights Simplified callback for updates Uses updated fiblary3 for color light handling * Lean and mean refactor While waiting for a brave reviewer, I've been making the code smaller and easier to understand. * Minor fixes to please HoundCI * Removed unused component Scenes are not implemented yet * Nicer comments. * DEVICE_CLASS, ignore plugins, improved mapping Added support for device class and icons in sensors and binary_sensors Improved mapping of sensors and added heuristic matching Added support for hidden devices Fixed conversion to float in sensors * Fixed dimming Fibaro apparently does not need, nor like the extra turnOn commands for dimmers * flake8 * Cleanup, Light fixes, switch power Cleanup of the component to separate init from connect, handle connection error better Improved light handling, especially for RGBW strips and working around Fibaro quirks Added energy and power reporting to switches * Missing comment added Missing comment added to please flake8 * Removed everything but bin.sensors Stripdown, hoping for a review * better aligned comments OMG * Fixes based on code review Fixes based on code review * Implemented stopping Implemented stopping of StateHandler thread Cleanup for clarity * Minor fix Removed unnecessary list copying * Nicer wording on shutdown * Minor changes based on code review * minor fixes based on code review * removed extra line break --- .coveragerc | 3 + .../components/binary_sensor/fibaro.py | 74 ++++ homeassistant/components/fibaro.py | 338 ++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 418 insertions(+) create mode 100644 homeassistant/components/binary_sensor/fibaro.py create mode 100644 homeassistant/components/fibaro.py diff --git a/.coveragerc b/.coveragerc index b6273ebc191..21589759084 100644 --- a/.coveragerc +++ b/.coveragerc @@ -120,6 +120,9 @@ omit = homeassistant/components/eufy.py homeassistant/components/*/eufy.py + homeassistant/components/fibaro.py + homeassistant/components/*/fibaro.py + homeassistant/components/gc100.py homeassistant/components/*/gc100.py diff --git a/homeassistant/components/binary_sensor/fibaro.py b/homeassistant/components/binary_sensor/fibaro.py new file mode 100644 index 00000000000..124ff88a9a3 --- /dev/null +++ b/homeassistant/components/binary_sensor/fibaro.py @@ -0,0 +1,74 @@ +""" +Support for Fibaro binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.fibaro/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = { + 'com.fibaro.doorSensor': ['Door', 'mdi:window-open', 'door'], + 'com.fibaro.windowSensor': ['Window', 'mdi:window-open', 'window'], + 'com.fibaro.smokeSensor': ['Smoke', 'mdi:smoking', 'smoke'], + 'com.fibaro.FGMS001': ['Motion', 'mdi:run', 'motion'], + 'com.fibaro.heatDetector': ['Heat', 'mdi:fire', 'heat'], +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Perform the setup for Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroBinarySensor(device, hass.data[FIBARO_CONTROLLER]) + for device in hass.data[FIBARO_DEVICES]['binary_sensor']], True) + + +class FibaroBinarySensor(FibaroDevice, BinarySensorDevice): + """Representation of a Fibaro Binary Sensor.""" + + def __init__(self, fibaro_device, controller): + """Initialize the binary_sensor.""" + self._state = None + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + stype = None + if fibaro_device.type in SENSOR_TYPES: + stype = fibaro_device.type + elif fibaro_device.baseType in SENSOR_TYPES: + stype = fibaro_device.baseType + if stype: + self._device_class = SENSOR_TYPES[stype][2] + self._icon = SENSOR_TYPES[stype][1] + else: + self._device_class = None + self._icon = None + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + def update(self): + """Get the latest data and update the state.""" + self._state = self.current_binary_state diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py new file mode 100644 index 00000000000..9a9e5b12851 --- /dev/null +++ b/homeassistant/components/fibaro.py @@ -0,0 +1,338 @@ +""" +Support for the Fibaro devices. + +For more details about this platform, please refer to the documentation. +https://home-assistant.io/components/fibaro/ +""" + +import logging +from collections import defaultdict +import voluptuous as vol + +from homeassistant.const import (ATTR_ARMED, ATTR_BATTERY_LEVEL, + CONF_PASSWORD, CONF_URL, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import convert, slugify +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['fiblary3==0.1.7'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'fibaro' +FIBARO_DEVICES = 'fibaro_devices' +FIBARO_CONTROLLER = 'fibaro_controller' +FIBARO_ID_FORMAT = '{}_{}_{}' +ATTR_CURRENT_POWER_W = "current_power_w" +ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" +CONF_PLUGINS = "plugins" + +FIBARO_COMPONENTS = [ + 'binary_sensor', +] + +FIBARO_TYPEMAP = { + 'com.fibaro.multilevelSensor': "sensor", + 'com.fibaro.binarySwitch': 'switch', + 'com.fibaro.multilevelSwitch': 'switch', + 'com.fibaro.FGD212': 'light', + 'com.fibaro.FGR': 'cover', + 'com.fibaro.doorSensor': 'binary_sensor', + 'com.fibaro.doorWindowSensor': 'binary_sensor', + 'com.fibaro.FGMS001': 'binary_sensor', + 'com.fibaro.heatDetector': 'binary_sensor', + 'com.fibaro.lifeDangerSensor': 'binary_sensor', + 'com.fibaro.smokeSensor': 'binary_sensor', + 'com.fibaro.remoteSwitch': 'switch', + 'com.fibaro.sensor': 'sensor', + 'com.fibaro.colorController': 'light' +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_PLUGINS, default=False): cv.boolean, + }) +}, extra=vol.ALLOW_EXTRA) + + +class FibaroController(): + """Initiate Fibaro Controller Class.""" + + _room_map = None # Dict for mapping roomId to room object + _device_map = None # Dict for mapping deviceId to device object + fibaro_devices = None # List of devices by type + _callbacks = {} # Dict of update value callbacks by deviceId + _client = None # Fiblary's Client object for communication + _state_handler = None # Fiblary's StateHandler object + _import_plugins = None # Whether to import devices from plugins + + def __init__(self, username, password, url, import_plugins): + """Initialize the Fibaro controller.""" + from fiblary3.client.v4.client import Client as FibaroClient + self._client = FibaroClient(url, username, password) + + def connect(self): + """Start the communication with the Fibaro controller.""" + try: + login = self._client.login.get() + except AssertionError: + _LOGGER.error("Can't connect to Fibaro HC. " + "Please check URL.") + return False + if login is None or login.status is False: + _LOGGER.error("Invalid login for Fibaro HC. " + "Please check username and password.") + return False + + self._room_map = {room.id: room for room in self._client.rooms.list()} + self._read_devices() + return True + + def enable_state_handler(self): + """Start StateHandler thread for monitoring updates.""" + from fiblary3.client.v4.client import StateHandler + self._state_handler = StateHandler(self._client, self._on_state_change) + + def disable_state_handler(self): + """Stop StateHandler thread used for monitoring updates.""" + self._state_handler.stop() + self._state_handler = None + + def _on_state_change(self, state): + """Handle change report received from the HomeCenter.""" + callback_set = set() + for change in state.get('changes', []): + dev_id = change.pop('id') + for property_name, value in change.items(): + if property_name == 'log': + if value and value != "transfer OK": + _LOGGER.debug("LOG %s: %s", + self._device_map[dev_id].friendly_name, + value) + continue + if property_name == 'logTemp': + continue + if property_name in self._device_map[dev_id].properties: + self._device_map[dev_id].properties[property_name] = \ + value + _LOGGER.debug("<- %s.%s = %s", + self._device_map[dev_id].ha_id, + property_name, + str(value)) + else: + _LOGGER.warning("Error updating %s data of %s, not found", + property_name, + self._device_map[dev_id].ha_id) + if dev_id in self._callbacks: + callback_set.add(dev_id) + for item in callback_set: + self._callbacks[item]() + + def register(self, device_id, callback): + """Register device with a callback for updates.""" + self._callbacks[device_id] = callback + + @staticmethod + def _map_device_to_type(device): + """Map device to HA device type.""" + # Use our lookup table to identify device type + device_type = FIBARO_TYPEMAP.get( + device.type, FIBARO_TYPEMAP.get(device.baseType)) + + # We can also identify device type by its capabilities + if device_type is None: + if 'setBrightness' in device.actions: + device_type = 'light' + elif 'turnOn' in device.actions: + device_type = 'switch' + elif 'open' in device.actions: + device_type = 'cover' + elif 'value' in device.properties: + if device.properties.value in ('true', 'false'): + device_type = 'binary_sensor' + else: + device_type = 'sensor' + + # Switches that control lights should show up as lights + if device_type == 'switch' and \ + 'isLight' in device.properties and \ + device.properties.isLight == 'true': + device_type = 'light' + return device_type + + def _read_devices(self): + """Read and process the device list.""" + devices = self._client.devices.list() + self._device_map = {} + for device in devices: + if device.roomID == 0: + room_name = 'Unknown' + else: + room_name = self._room_map[device.roomID].name + device.friendly_name = room_name + ' ' + device.name + device.ha_id = FIBARO_ID_FORMAT.format( + slugify(room_name), slugify(device.name), device.id) + self._device_map[device.id] = device + self.fibaro_devices = defaultdict(list) + for device in self._device_map.values(): + if device.enabled and \ + (not device.isPlugin or self._import_plugins): + device.mapped_type = self._map_device_to_type(device) + if device.mapped_type: + self.fibaro_devices[device.mapped_type].append(device) + else: + _LOGGER.debug("%s (%s, %s) not mapped", + device.ha_id, device.type, + device.baseType) + + +def setup(hass, config): + """Set up the Fibaro Component.""" + hass.data[FIBARO_CONTROLLER] = controller = \ + FibaroController(config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + config[DOMAIN][CONF_URL], + config[DOMAIN][CONF_PLUGINS]) + + def stop_fibaro(event): + """Stop Fibaro Thread.""" + _LOGGER.info("Shutting down Fibaro connection") + hass.data[FIBARO_CONTROLLER].disable_state_handler() + + if controller.connect(): + hass.data[FIBARO_DEVICES] = controller.fibaro_devices + for component in FIBARO_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + controller.enable_state_handler() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_fibaro) + return True + + return False + + +class FibaroDevice(Entity): + """Representation of a Fibaro device entity.""" + + def __init__(self, fibaro_device, controller): + """Initialize the device.""" + self.fibaro_device = fibaro_device + self.controller = controller + self._name = fibaro_device.friendly_name + self.ha_id = fibaro_device.ha_id + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.controller.register(self.fibaro_device.id, self._update_callback) + + def _update_callback(self): + """Update the state.""" + self.schedule_update_ha_state(True) + + def get_level(self): + """Get the level of Fibaro device.""" + if 'value' in self.fibaro_device.properties: + return self.fibaro_device.properties.value + return None + + def get_level2(self): + """Get the tilt level of Fibaro device.""" + if 'value2' in self.fibaro_device.properties: + return self.fibaro_device.properties.value2 + return None + + def dont_know_message(self, action): + """Make a warning in case we don't know how to perform an action.""" + _LOGGER.warning("Not sure how to setValue: %s " + "(available actions: %s)", str(self.ha_id), + str(self.fibaro_device.actions)) + + def set_level(self, level): + """Set the level of Fibaro device.""" + self.action("setValue", level) + if 'value' in self.fibaro_device.properties: + self.fibaro_device.properties.value = level + if 'brightness' in self.fibaro_device.properties: + self.fibaro_device.properties.brightness = level + + def set_color(self, red, green, blue, white): + """Set the color of Fibaro device.""" + color_str = "{},{},{},{}".format(int(red), int(green), + int(blue), int(white)) + self.fibaro_device.properties.color = color_str + self.action("setColor", str(int(red)), str(int(green)), + str(int(blue)), str(int(white))) + + def action(self, cmd, *args): + """Perform an action on the Fibaro HC.""" + if cmd in self.fibaro_device.actions: + getattr(self.fibaro_device, cmd)(*args) + _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), + str(cmd), str(args)) + else: + self.dont_know_message(cmd) + + @property + def hidden(self) -> bool: + """Return True if the entity should be hidden from UIs.""" + return self.fibaro_device.visible is False + + @property + def current_power_w(self): + """Return the current power usage in W.""" + if 'power' in self.fibaro_device.properties: + power = self.fibaro_device.properties.power + if power: + return convert(power, float, 0.0) + else: + return None + + @property + def current_binary_state(self): + """Return the current binary state.""" + if self.fibaro_device.properties.value == 'false': + return False + if self.fibaro_device.properties.value == 'true' or \ + int(self.fibaro_device.properties.value) > 0: + return True + return False + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Get polling requirement from fibaro device.""" + return False + + def update(self): + """Call to update state.""" + pass + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + + try: + if 'battery' in self.fibaro_device.interfaces: + attr[ATTR_BATTERY_LEVEL] = \ + int(self.fibaro_device.properties.batteryLevel) + if 'fibaroAlarmArm' in self.fibaro_device.interfaces: + attr[ATTR_ARMED] = bool(self.fibaro_device.properties.armed) + if 'power' in self.fibaro_device.interfaces: + attr[ATTR_CURRENT_POWER_W] = convert( + self.fibaro_device.properties.power, float, 0.0) + if 'energy' in self.fibaro_device.interfaces: + attr[ATTR_CURRENT_ENERGY_KWH] = convert( + self.fibaro_device.properties.energy, float, 0.0) + except (ValueError, KeyError): + pass + + attr['id'] = self.ha_id + return attr diff --git a/requirements_all.txt b/requirements_all.txt index 1a880b5d7d3..429567f9bf2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -373,6 +373,9 @@ fedexdeliverymanager==1.0.6 # homeassistant.components.feedreader feedparser==5.2.1 +# homeassistant.components.fibaro +fiblary3==0.1.7 + # homeassistant.components.sensor.fints fints==1.0.1 From 8aa1283adc6eb9050e6c5b5594eb3bcf1cf7e01f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 14 Nov 2018 13:23:49 -0700 Subject: [PATCH 159/238] Add Rainmachine config entry (#18419) * Initial stuff * More work in place * Starting with tests * Device registry in place * Hound * Linting * Member comments (including extracting device registry) * Member comments (plus I forgot cleanup!) * Hound * More Hound * Removed old import * Adding config entry test to coverage * Updated strings --- .coveragerc | 2 +- .../components/binary_sensor/rainmachine.py | 40 +++--- .../rainmachine/.translations/en.json | 19 +++ .../components/rainmachine/__init__.py | 128 ++++++++++++------ .../components/rainmachine/config_flow.py | 85 ++++++++++++ homeassistant/components/rainmachine/const.py | 14 ++ .../components/rainmachine/strings.json | 19 +++ .../components/sensor/rainmachine.py | 36 +++-- .../components/switch/rainmachine.py | 27 ++-- homeassistant/config_entries.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/rainmachine/__init__.py | 1 + .../rainmachine/test_config_flow.py | 109 +++++++++++++++ 15 files changed, 400 insertions(+), 87 deletions(-) create mode 100644 homeassistant/components/rainmachine/.translations/en.json create mode 100644 homeassistant/components/rainmachine/config_flow.py create mode 100644 homeassistant/components/rainmachine/const.py create mode 100644 homeassistant/components/rainmachine/strings.json create mode 100644 tests/components/rainmachine/__init__.py create mode 100644 tests/components/rainmachine/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 21589759084..2762dffbeb1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -271,7 +271,7 @@ omit = homeassistant/components/raincloud.py homeassistant/components/*/raincloud.py - homeassistant/components/rainmachine/* + homeassistant/components/rainmachine/__init__.py homeassistant/components/*/rainmachine.py homeassistant/components/raspihats.py diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py index 12c9b3e98f0..88b2dd22d52 100644 --- a/homeassistant/components/binary_sensor/rainmachine.py +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -8,28 +8,29 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.rainmachine import ( - BINARY_SENSORS, DATA_RAINMACHINE, SENSOR_UPDATE_TOPIC, TYPE_FREEZE, - TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, TYPE_HOURLY, TYPE_MONTH, - TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, RainMachineEntity) -from homeassistant.const import CONF_MONITORED_CONDITIONS + BINARY_SENSORS, DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, + SENSOR_UPDATE_TOPIC, TYPE_FREEZE, TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, + TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, + RainMachineEntity) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['rainmachine'] - _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the RainMachine Switch platform.""" - if discovery_info is None: - return + """Set up RainMachine binary sensors based on the old way.""" + pass - rainmachine = hass.data[DATA_RAINMACHINE] + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up RainMachine binary sensors based on a config entry.""" + rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] binary_sensors = [] - for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + for sensor_type in rainmachine.binary_sensor_conditions: name, icon = BINARY_SENSORS[sensor_type] binary_sensors.append( RainMachineBinarySensor(rainmachine, sensor_type, name, icon)) @@ -70,15 +71,20 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): return '{0}_{1}'.format( self.rainmachine.device_mac.replace(':', ''), self._sensor_type) - @callback - def _update_data(self): - """Update the state.""" - self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SENSOR_UPDATE_TOPIC, self._update_data) + @callback + def update(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, SENSOR_UPDATE_TOPIC, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() async def async_update(self): """Update the state.""" diff --git a/homeassistant/components/rainmachine/.translations/en.json b/homeassistant/components/rainmachine/.translations/en.json new file mode 100644 index 00000000000..54b67066f2b --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account already registered", + "invalid_credentials": "Invalid credentials" + }, + "step": { + "user": { + "data": { + "ip_address": "Hostname or IP Address", + "password": "Password", + "port": "Port" + }, + "title": "Fill in your information" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 9f15c8b373f..5778d9db4df 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -9,25 +9,25 @@ from datetime import timedelta import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_BINARY_SENSORS, CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SSL, CONF_MONITORED_CONDITIONS, CONF_SWITCHES) -from homeassistant.helpers import ( - aiohttp_client, config_validation as cv, discovery) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['regenmaschine==1.0.2'] +from .config_flow import configured_instances +from .const import DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN + +REQUIREMENTS = ['regenmaschine==1.0.7'] _LOGGER = logging.getLogger(__name__) -DATA_RAINMACHINE = 'data_rainmachine' -DOMAIN = 'rainmachine' - -NOTIFICATION_ID = 'rainmachine_notification' -NOTIFICATION_TITLE = 'RainMachine Component Setup' +DATA_LISTENER = 'listener' PROGRAM_UPDATE_TOPIC = '{0}_program_update'.format(DOMAIN) SENSOR_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) @@ -39,8 +39,6 @@ CONF_ZONE_RUN_TIME = 'zone_run_time' DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' DEFAULT_ICON = 'mdi:water' -DEFAULT_PORT = 8080 -DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) DEFAULT_SSL = True DEFAULT_ZONE_RUN = 60 * 10 @@ -120,48 +118,73 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up the RainMachine component.""" - from regenmaschine import Client - from regenmaschine.errors import RequestError + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + hass.data[DOMAIN][DATA_LISTENER] = {} + + if DOMAIN not in config: + return True conf = config[DOMAIN] - ip_address = conf[CONF_IP_ADDRESS] - password = conf[CONF_PASSWORD] - port = conf[CONF_PORT] - ssl = conf[CONF_SSL] + + if conf[CONF_IP_ADDRESS] in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data=conf)) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up RainMachine as config entry.""" + from regenmaschine import login + from regenmaschine.errors import RainMachineError + + ip_address = config_entry.data[CONF_IP_ADDRESS] + password = config_entry.data[CONF_PASSWORD] + port = config_entry.data[CONF_PORT] + ssl = config_entry.data.get(CONF_SSL, DEFAULT_SSL) + + websession = aiohttp_client.async_get_clientsession(hass) try: - websession = aiohttp_client.async_get_clientsession(hass) - client = Client(ip_address, websession, port=port, ssl=ssl) - await client.authenticate(password) - rainmachine = RainMachine(client) + client = await login( + ip_address, password, websession, port=port, ssl=ssl) + rainmachine = RainMachine( + client, + config_entry.data.get(CONF_BINARY_SENSORS, {}).get( + CONF_MONITORED_CONDITIONS, list(BINARY_SENSORS)), + config_entry.data.get(CONF_SENSORS, {}).get( + CONF_MONITORED_CONDITIONS, list(SENSORS)), + config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN) + ) await rainmachine.async_update() - hass.data[DATA_RAINMACHINE] = rainmachine - except RequestError as err: - _LOGGER.error('An error occurred: %s', str(err)) - hass.components.persistent_notification.create( - 'Error: {0}
' - 'You will need to restart hass after fixing.' - ''.format(err), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False + except RainMachineError as err: + _LOGGER.error('An error occurred: %s', err) + raise ConfigEntryNotReady - for component, schema in [ - ('binary_sensor', conf[CONF_BINARY_SENSORS]), - ('sensor', conf[CONF_SENSORS]), - ('switch', conf[CONF_SWITCHES]), - ]: + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = rainmachine + + for component in ('binary_sensor', 'sensor', 'switch'): hass.async_create_task( - discovery.async_load_platform(hass, component, DOMAIN, schema, - config)) + hass.config_entries.async_forward_entry_setup( + config_entry, component)) - async def refresh_sensors(event_time): + async def refresh(event_time): """Refresh RainMachine sensor data.""" _LOGGER.debug('Updating RainMachine sensor data') await rainmachine.async_update() async_dispatcher_send(hass, SENSOR_UPDATE_TOPIC) - async_track_time_interval(hass, refresh_sensors, conf[CONF_SCAN_INTERVAL]) + hass.data[DOMAIN][DATA_LISTENER][ + config_entry.entry_id] = async_track_time_interval( + hass, + refresh, + timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) async def start_program(service): """Start a particular program.""" @@ -170,8 +193,8 @@ async def async_setup(hass, config): async def start_zone(service): """Start a particular zone for a certain amount of time.""" - await rainmachine.client.zones.start(service.data[CONF_ZONE_ID], - service.data[CONF_ZONE_RUN_TIME]) + await rainmachine.client.zones.start( + service.data[CONF_ZONE_ID], service.data[CONF_ZONE_RUN_TIME]) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) async def stop_all(service): @@ -201,14 +224,34 @@ async def async_setup(hass, config): return True +async def async_unload_entry(hass, config_entry): + """Unload an OpenUV config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop( + config_entry.entry_id) + remove_listener() + + for component in ('binary_sensor', 'sensor', 'switch'): + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + + return True + + class RainMachine: """Define a generic RainMachine object.""" - def __init__(self, client): + def __init__( + self, client, binary_sensor_conditions, sensor_conditions, + default_zone_runtime): """Initialize.""" + self.binary_sensor_conditions = binary_sensor_conditions self.client = client + self.default_zone_runtime = default_zone_runtime self.device_mac = self.client.mac self.restrictions = {} + self.sensor_conditions = sensor_conditions async def async_update(self): """Update sensor/binary sensor data.""" @@ -224,6 +267,7 @@ class RainMachineEntity(Entity): def __init__(self, rainmachine): """Initialize.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._async_unsub_dispatcher_connect = None self._name = None self.rainmachine = rainmachine diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py new file mode 100644 index 00000000000..ecf497333cb --- /dev/null +++ b/homeassistant/components/rainmachine/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow to configure the RainMachine component.""" + +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL) +from homeassistant.helpers import aiohttp_client + +from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured RainMachine instances.""" + return set( + entry.data[CONF_IP_ADDRESS] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class RainMachineFlowHandler(config_entries.ConfigFlow): + """Handle a RainMachine config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the config flow.""" + self.data_schema = OrderedDict() + self.data_schema[vol.Required(CONF_IP_ADDRESS)] = str + self.data_schema[vol.Required(CONF_PASSWORD)] = str + self.data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from regenmaschine import login + from regenmaschine.errors import RainMachineError + + if not user_input: + return await self._show_form() + + if user_input[CONF_IP_ADDRESS] in configured_instances(self.hass): + return await self._show_form({ + CONF_IP_ADDRESS: 'identifier_exists' + }) + + websession = aiohttp_client.async_get_clientsession(self.hass) + + try: + await login( + user_input[CONF_IP_ADDRESS], + user_input[CONF_PASSWORD], + websession, + port=user_input.get(CONF_PORT, DEFAULT_PORT), + ssl=True) + except RainMachineError: + return await self._show_form({ + CONF_PASSWORD: 'invalid_credentials' + }) + + scan_interval = user_input.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds + + # Unfortunately, RainMachine doesn't provide a way to refresh the + # access token without using the IP address and password, so we have to + # store it: + return self.async_create_entry( + title=user_input[CONF_IP_ADDRESS], data=user_input) diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py new file mode 100644 index 00000000000..ec1f0436ccb --- /dev/null +++ b/homeassistant/components/rainmachine/const.py @@ -0,0 +1,14 @@ +"""Define constants for the SimpliSafe component.""" +import logging +from datetime import timedelta + +LOGGER = logging.getLogger('homeassistant.components.rainmachine') + +DOMAIN = 'rainmachine' + +DATA_CLIENT = 'client' + +DEFAULT_PORT = 8080 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) + +TOPIC_UPDATE = 'update_{0}' diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json new file mode 100644 index 00000000000..6e26192ec82 --- /dev/null +++ b/homeassistant/components/rainmachine/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "RainMachine", + "step": { + "user": { + "title": "Fill in your information", + "data": { + "ip_address": "Hostname or IP Address", + "password": "Password", + "port": "Port" + } + } + }, + "error": { + "identifier_exists": "Account already registered", + "invalid_credentials": "Invalid credentials" + } + } +} diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/sensor/rainmachine.py index 20e95f0e98f..59efd4c47f6 100644 --- a/homeassistant/components/sensor/rainmachine.py +++ b/homeassistant/components/sensor/rainmachine.py @@ -7,26 +7,27 @@ https://home-assistant.io/components/sensor.rainmachine/ import logging from homeassistant.components.rainmachine import ( - DATA_RAINMACHINE, SENSOR_UPDATE_TOPIC, SENSORS, RainMachineEntity) -from homeassistant.const import CONF_MONITORED_CONDITIONS + DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, SENSOR_UPDATE_TOPIC, SENSORS, + RainMachineEntity) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['rainmachine'] - _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the RainMachine Switch platform.""" - if discovery_info is None: - return + """Set up RainMachine sensors based on the old way.""" + pass - rainmachine = hass.data[DATA_RAINMACHINE] + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up RainMachine sensors based on a config entry.""" + rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] sensors = [] - for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + for sensor_type in rainmachine.sensor_conditions: name, icon, unit = SENSORS[sensor_type] sensors.append( RainMachineSensor(rainmachine, sensor_type, name, icon, unit)) @@ -73,15 +74,20 @@ class RainMachineSensor(RainMachineEntity): """Return the unit the value is expressed in.""" return self._unit - @callback - def _update_data(self): - """Update the state.""" - self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SENSOR_UPDATE_TOPIC, self._update_data) + @callback + def update(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, SENSOR_UPDATE_TOPIC, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() async def async_update(self): """Update the sensor's state.""" diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 633a3e50a09..5d03b2691eb 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -7,8 +7,8 @@ https://home-assistant.io/components/switch.rainmachine/ import logging from homeassistant.components.rainmachine import ( - CONF_ZONE_RUN_TIME, DATA_RAINMACHINE, DEFAULT_ZONE_RUN, - PROGRAM_UPDATE_TOPIC, ZONE_UPDATE_TOPIC, RainMachineEntity) + DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, PROGRAM_UPDATE_TOPIC, + ZONE_UPDATE_TOPIC, RainMachineEntity) from homeassistant.const import ATTR_ID from homeassistant.components.switch import SwitchDevice from homeassistant.core import callback @@ -101,15 +101,13 @@ VEGETATION_MAP = { async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the RainMachine Switch platform.""" - if discovery_info is None: - return + """Set up RainMachine switches sensor based on the old way.""" + pass - _LOGGER.debug('Config received: %s', discovery_info) - zone_run_time = discovery_info.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN) - - rainmachine = hass.data[DATA_RAINMACHINE] +async def async_setup_entry(hass, entry, async_add_entities): + """Set up RainMachine switches based on a config entry.""" + rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] entities = [] @@ -127,7 +125,9 @@ async def async_setup_platform( continue _LOGGER.debug('Adding zone: %s', zone) - entities.append(RainMachineZone(rainmachine, zone, zone_run_time)) + entities.append( + RainMachineZone( + rainmachine, zone, rainmachine.default_zone_runtime)) async_add_entities(entities, True) @@ -186,9 +186,14 @@ class RainMachineProgram(RainMachineSwitch): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( + self._async_unsub_dispatcher_connect = async_dispatcher_connect( self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated) + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + async def async_turn_off(self, **kwargs) -> None: """Turn the program off.""" from regenmaschine.errors import RequestError diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 513f225db03..6669d5240d8 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ FLOWS = [ 'mqtt', 'nest', 'openuv', + 'rainmachine', 'simplisafe', 'smhi', 'sonos', diff --git a/requirements_all.txt b/requirements_all.txt index 429567f9bf2..1545440f8b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1336,7 +1336,7 @@ raincloudy==0.0.5 # raspihats==2.2.3 # homeassistant.components.rainmachine -regenmaschine==1.0.2 +regenmaschine==1.0.7 # homeassistant.components.python_script restrictedpython==4.0b6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b94a0ac00ab..49cc4dd1102 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -210,6 +210,9 @@ pyunifi==2.13 # homeassistant.components.notify.html5 pywebpush==1.6.0 +# homeassistant.components.rainmachine +regenmaschine==1.0.7 + # homeassistant.components.python_script restrictedpython==4.0b6 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 698b35e776f..cf5791ef38d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -95,6 +95,7 @@ TEST_REQUIREMENTS = ( 'pyunifi', 'pyupnp-async', 'pywebpush', + 'regenmaschine', 'restrictedpython', 'rflink', 'ring_doorbell', diff --git a/tests/components/rainmachine/__init__.py b/tests/components/rainmachine/__init__.py new file mode 100644 index 00000000000..d6bd6a5dd95 --- /dev/null +++ b/tests/components/rainmachine/__init__.py @@ -0,0 +1 @@ +"""Define tests for the RainMachine component.""" diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py new file mode 100644 index 00000000000..2291ac23749 --- /dev/null +++ b/tests/components/rainmachine/test_config_flow.py @@ -0,0 +1,109 @@ +"""Define tests for the OpenUV config flow.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.rainmachine import DOMAIN, config_flow +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_SCAN_INTERVAL) + +from tests.common import MockConfigEntry, mock_coro + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_IP_ADDRESS: 'identifier_exists'} + + +async def test_invalid_password(hass): + """Test that an invalid password throws an error.""" + from regenmaschine.errors import RainMachineError + + conf = { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'bad_password', + CONF_PORT: 8080, + CONF_SSL: True, + } + + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + with patch('regenmaschine.login', + return_value=mock_coro(exception=RainMachineError)): + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_PASSWORD: 'invalid_credentials'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + } + + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + with patch('regenmaschine.login', return_value=mock_coro(True)): + result = await flow.async_step_import(import_config=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '192.168.1.100' + assert result['data'] == { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + CONF_SCAN_INTERVAL: 60, + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + } + + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + with patch('regenmaschine.login', return_value=mock_coro(True)): + result = await flow.async_step_user(user_input=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '192.168.1.100' + assert result['data'] == { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + CONF_SCAN_INTERVAL: 60, + } From 75bb78d440358266f7995c3822977f0eb2078292 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 15 Nov 2018 14:30:37 +0100 Subject: [PATCH 160/238] Update translations --- .../components/auth/.translations/cs.json | 26 ++++++++++++++ .../components/auth/.translations/es.json | 35 +++++++++++++++++++ .../components/auth/.translations/it.json | 21 +++++++++++ .../components/cast/.translations/es.json | 10 ++++++ .../components/deconz/.translations/es.json | 33 +++++++++++++++++ .../components/deconz/.translations/no.json | 2 +- .../dialogflow/.translations/cs.json | 18 ++++++++++ .../dialogflow/.translations/es.json | 14 ++++++++ .../components/hangouts/.translations/cs.json | 29 +++++++++++++++ .../components/hangouts/.translations/es.json | 31 ++++++++++++++++ .../homematicip_cloud/.translations/es.json | 17 +++++++-- .../components/hue/.translations/es.json | 20 ++++++++++- .../components/ifttt/.translations/cs.json | 7 ++++ .../components/ifttt/.translations/es.json | 7 ++++ .../components/ifttt/.translations/it.json | 18 ++++++++++ .../components/ios/.translations/es.json | 14 ++++++++ .../components/ios/.translations/it.json | 5 +++ .../luftdaten/.translations/cs.json | 19 ++++++++++ .../luftdaten/.translations/es.json | 19 ++++++++++ .../luftdaten/.translations/ko.json | 19 ++++++++++ .../luftdaten/.translations/lb.json | 19 ++++++++++ .../luftdaten/.translations/pl.json | 19 ++++++++++ .../luftdaten/.translations/ru.json | 19 ++++++++++ .../luftdaten/.translations/sl.json | 19 ++++++++++ .../components/mailgun/.translations/cs.json | 18 ++++++++++ .../components/mailgun/.translations/es.json | 17 +++++++++ .../components/mqtt/.translations/cs.json | 30 ++++++++++++++++ .../components/mqtt/.translations/es.json | 20 ++++++++++- .../components/mqtt/.translations/it.json | 24 +++++++++++++ .../components/mqtt/.translations/ru.json | 4 +-- .../components/nest/.translations/es.json | 23 +++++++++++- .../components/openuv/.translations/es.json | 20 +++++++++++ .../rainmachine/.translations/ko.json | 19 ++++++++++ .../rainmachine/.translations/lb.json | 19 ++++++++++ .../rainmachine/.translations/ru.json | 19 ++++++++++ .../sensor/.translations/moon.es.json | 5 ++- .../sensor/.translations/moon.fi.json | 12 +++++++ .../sensor/.translations/moon.it.json | 1 + .../simplisafe/.translations/cs.json | 3 +- .../simplisafe/.translations/ko.json | 2 +- .../components/sonos/.translations/es.json | 5 +++ .../components/tradfri/.translations/cs.json | 22 ++++++++++++ .../components/tradfri/.translations/es.json | 23 ++++++++++++ .../components/tradfri/.translations/it.json | 5 +++ .../components/twilio/.translations/cs.json | 18 ++++++++++ .../components/twilio/.translations/es.json | 17 +++++++++ .../components/unifi/.translations/cs.json | 25 +++++++++++++ .../components/upnp/.translations/cs.json | 11 ++++++ .../components/upnp/.translations/es.json | 1 + .../components/zone/.translations/es.json | 21 +++++++++++ .../components/zwave/.translations/es.json | 1 + 51 files changed, 813 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/auth/.translations/cs.json create mode 100644 homeassistant/components/auth/.translations/es.json create mode 100644 homeassistant/components/deconz/.translations/es.json create mode 100644 homeassistant/components/dialogflow/.translations/cs.json create mode 100644 homeassistant/components/dialogflow/.translations/es.json create mode 100644 homeassistant/components/hangouts/.translations/cs.json create mode 100644 homeassistant/components/hangouts/.translations/es.json create mode 100644 homeassistant/components/ifttt/.translations/it.json create mode 100644 homeassistant/components/ios/.translations/es.json create mode 100644 homeassistant/components/ios/.translations/it.json create mode 100644 homeassistant/components/luftdaten/.translations/cs.json create mode 100644 homeassistant/components/luftdaten/.translations/es.json create mode 100644 homeassistant/components/luftdaten/.translations/ko.json create mode 100644 homeassistant/components/luftdaten/.translations/lb.json create mode 100644 homeassistant/components/luftdaten/.translations/pl.json create mode 100644 homeassistant/components/luftdaten/.translations/ru.json create mode 100644 homeassistant/components/luftdaten/.translations/sl.json create mode 100644 homeassistant/components/mailgun/.translations/cs.json create mode 100644 homeassistant/components/mailgun/.translations/es.json create mode 100644 homeassistant/components/mqtt/.translations/cs.json create mode 100644 homeassistant/components/mqtt/.translations/it.json create mode 100644 homeassistant/components/openuv/.translations/es.json create mode 100644 homeassistant/components/rainmachine/.translations/ko.json create mode 100644 homeassistant/components/rainmachine/.translations/lb.json create mode 100644 homeassistant/components/rainmachine/.translations/ru.json create mode 100644 homeassistant/components/sensor/.translations/moon.fi.json create mode 100644 homeassistant/components/tradfri/.translations/cs.json create mode 100644 homeassistant/components/tradfri/.translations/es.json create mode 100644 homeassistant/components/tradfri/.translations/it.json create mode 100644 homeassistant/components/twilio/.translations/cs.json create mode 100644 homeassistant/components/twilio/.translations/es.json create mode 100644 homeassistant/components/unifi/.translations/cs.json create mode 100644 homeassistant/components/zone/.translations/es.json diff --git a/homeassistant/components/auth/.translations/cs.json b/homeassistant/components/auth/.translations/cs.json new file mode 100644 index 00000000000..508ffac6739 --- /dev/null +++ b/homeassistant/components/auth/.translations/cs.json @@ -0,0 +1,26 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u017d\u00e1dn\u00e9 oznamovac\u00ed slu\u017eby nejsou k dispozici." + }, + "error": { + "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu." + }, + "step": { + "init": { + "description": "Vyberte pros\u00edm jednu z oznamovac\u00edch slu\u017eeb:", + "title": "Nastavte jednor\u00e1zov\u00e9 heslo dodan\u00e9 komponentou notify" + }, + "setup": { + "title": "Ov\u011b\u0159en\u00ed nastaven\u00ed" + } + } + }, + "totp": { + "error": { + "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu. Pokud se tato chyba opakuje, ujist\u011bte se, \u017ee hodiny syst\u00e9mu Home Assistant jsou spr\u00e1vn\u011b nastaveny." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/es.json b/homeassistant/components/auth/.translations/es.json new file mode 100644 index 00000000000..bfec5cd9274 --- /dev/null +++ b/homeassistant/components/auth/.translations/es.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No hay servicios de notificaci\u00f3n disponibles." + }, + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntelo de nuevo." + }, + "step": { + "init": { + "description": "Seleccione uno de los servicios de notificaci\u00f3n:", + "title": "Configure una contrase\u00f1a de un solo uso entregada por el componente de notificaci\u00f3n" + }, + "setup": { + "description": "Se ha enviado una contrase\u00f1a de un solo uso a trav\u00e9s de ** notificar. {notify_service} **. Por favor introd\u00facela a continuaci\u00f3n:", + "title": "Verificar la configuraci\u00f3n" + } + }, + "title": "Notificar la contrase\u00f1a de un solo uso" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntalo de nuevo. Si recibes este error de forma consistente, por favor aseg\u00farate de que el reloj de tu Home Assistant es correcto." + }, + "step": { + "init": { + "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanea el c\u00f3digo QR con tu aplicaci\u00f3n de autenticaci\u00f3n. Si no tienes una, te recomendamos [Autenticador de Google] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \nDespu\u00e9s de escanear el c\u00f3digo, introduce el c\u00f3digo de seis d\u00edgitos de tu aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tienes problemas para escanear el c\u00f3digo QR, realiza una configuraci\u00f3n manual con el c\u00f3digo ** ` {code} ` **.", + "title": "Configure la autenticaci\u00f3n de dos factores utilizando TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/it.json b/homeassistant/components/auth/.translations/it.json index 869c3b438af..25dad4c1aeb 100644 --- a/homeassistant/components/auth/.translations/it.json +++ b/homeassistant/components/auth/.translations/it.json @@ -1,6 +1,27 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nessun servizio di notifica disponibile." + }, + "error": { + "invalid_code": "Codice non valido, per favore riprovare." + }, + "step": { + "init": { + "description": "Selezionare uno dei servizi di notifica:" + }, + "setup": { + "description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Per favore, inseriscila qui sotto:", + "title": "Verifica l'installazione" + } + }, + "title": "Notifica la Password monouso" + }, "totp": { + "error": { + "invalid_code": "Codice non valido, per favore riprovare. Se riscontri spesso questo errore, assicurati che l'orologio del sistema Home Assistant sia accurato." + }, "step": { "init": { "description": "Per attivare l'autenticazione a due fattori utilizzando password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Dopo aver scansionato il codice, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con codice ** ` {code} ` **.", diff --git a/homeassistant/components/cast/.translations/es.json b/homeassistant/components/cast/.translations/es.json index 9188055849c..6dc41196af5 100644 --- a/homeassistant/components/cast/.translations/es.json +++ b/homeassistant/components/cast/.translations/es.json @@ -1,5 +1,15 @@ { "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos de Google Cast en la red.", + "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres configurar Google Cast?", + "title": "Google Cast" + } + }, "title": "Google Cast" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json new file mode 100644 index 00000000000..34661f447d8 --- /dev/null +++ b/homeassistant/components/deconz/.translations/es.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "El puente ya esta configurado", + "no_bridges": "No se han descubierto puentes deCONZ", + "one_instance_only": "El componente s\u00f3lo soporta una instancia deCONZ" + }, + "error": { + "no_key": "No se pudo obtener una clave API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "title": "Definir pasarela deCONZ" + }, + "link": { + "description": "Desbloquee su pasarela deCONZ para registrarse con Home Assistant. \n\n 1. Ir a la configuraci\u00f3n del sistema deCONZ \n 2. Presione el bot\u00f3n \"Desbloquear Gateway\"", + "title": "Enlazar con deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir importar sensores virtuales", + "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ" + }, + "title": "Opciones de configuraci\u00f3n adicionales para deCONZ" + } + }, + "title": "Pasarela Zigbee deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index 27868814eab..1b0407e633d 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Vert", - "port": "Port (standardverdi: '80')" + "port": "Port" }, "title": "Definer deCONZ-gatewayen" }, diff --git a/homeassistant/components/dialogflow/.translations/cs.json b/homeassistant/components/dialogflow/.translations/cs.json new file mode 100644 index 00000000000..21da9b4823b --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Dialogflow.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit [integraci Dialogflow]({dialogflow_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: aplikace/json \n\n Podrobn\u011bj\u0161\u00ed informace naleznete v [dokumentaci]({docs_url})." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit Dialogflow?", + "title": "Nastavit Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/es.json b/homeassistant/components/dialogflow/.translations/es.json new file mode 100644 index 00000000000..892f0c5bfd0 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.", + "one_instance_allowed": "Solo una instancia es necesaria." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1 seguro de que desea configurar Dialogflow?" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/cs.json b/homeassistant/components/hangouts/.translations/cs.json new file mode 100644 index 00000000000..badd381f2be --- /dev/null +++ b/homeassistant/components/hangouts/.translations/cs.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Slu\u017eba Google Hangouts je ji\u017e nakonfigurov\u00e1na", + "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b" + }, + "error": { + "invalid_2fa": "Dfoufaktorov\u00e9 ov\u011b\u0159en\u00ed se nezda\u0159ilo. Zkuste to znovu.", + "invalid_2fa_method": "Neplatn\u00e1 metoda 2FA (ov\u011b\u0159en\u00ed na telefonu).", + "invalid_login": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed jm\u00e9no, pros\u00edm zkuste to znovu." + }, + "step": { + "2fa": { + "data": { + "2fa": "Dvoufaktorov\u00fd ov\u011b\u0159ovac\u00ed k\u00f3d" + }, + "title": "Dvoufaktorov\u00e9 ov\u011b\u0159en\u00ed" + }, + "user": { + "data": { + "email": "E-mailov\u00e1 adresa", + "password": "Heslo" + }, + "title": "P\u0159ihl\u00e1\u0161en\u00ed do slu\u017eby Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/es.json b/homeassistant/components/hangouts/.translations/es.json new file mode 100644 index 00000000000..4b7ad390ceb --- /dev/null +++ b/homeassistant/components/hangouts/.translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ya est\u00e1 configurado", + "unknown": "Error desconocido" + }, + "error": { + "invalid_2fa": "Autenticaci\u00f3n de 2 factores no v\u00e1lida, por favor, int\u00e9ntelo de nuevo.", + "invalid_2fa_method": "M\u00e9todo 2FA no v\u00e1lido (verificar en el tel\u00e9fono).", + "invalid_login": "Inicio de sesi\u00f3n no v\u00e1lido, por favor, int\u00e9ntalo de nuevo." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "description": "Vac\u00edo", + "title": "Autenticaci\u00f3n de 2 factores" + }, + "user": { + "data": { + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" + }, + "description": "Vac\u00edo", + "title": "Usuario Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/es.json b/homeassistant/components/homematicip_cloud/.translations/es.json index 3f16c45382b..185dbd338f9 100644 --- a/homeassistant/components/homematicip_cloud/.translations/es.json +++ b/homeassistant/components/homematicip_cloud/.translations/es.json @@ -1,19 +1,30 @@ { "config": { "abort": { + "already_configured": "El punto de acceso ya est\u00e1 configurado", + "connection_aborted": "No se pudo conectar al servidor HMIP", "unknown": "Se ha producido un error desconocido." }, "error": { "invalid_pin": "PIN no v\u00e1lido, por favor int\u00e9ntalo de nuevo.", - "press_the_button": "Por favor, pulsa el bot\u00f3n azul" + "press_the_button": "Por favor, pulsa el bot\u00f3n azul", + "register_failed": "No se pudo registrar, por favor intentelo de nuevo.", + "timeout_button": "Tiempo de espera agotado desde que se apret\u00f3 el bot\u00f3n azul, por favor, int\u00e9ntalo de nuevo." }, "step": { "init": { "data": { + "hapid": "ID de punto de acceso (SGTIN)", "name": "Nombre (opcional, utilizado como prefijo para todos los dispositivos)", "pin": "C\u00f3digo PIN (opcional)" - } + }, + "title": "Elegir punto de acceso HomematicIP" + }, + "link": { + "description": "Presione el bot\u00f3n azul en el punto de acceso y el bot\u00f3n enviar para registrar HomematicIP con Home Assistant. \n\n ! [Ubicaci\u00f3n del bot\u00f3n punto de acceso](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Enlazar punto de acceso" } - } + }, + "title": "HomematicIP Cloud" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/es.json b/homeassistant/components/hue/.translations/es.json index d58469af044..56e7ed62e9d 100644 --- a/homeassistant/components/hue/.translations/es.json +++ b/homeassistant/components/hue/.translations/es.json @@ -1,11 +1,29 @@ { "config": { "abort": { + "all_configured": "Todos los puentes Philips Hue ya est\u00e1n configurados", + "already_configured": "El puente ya esta configurado", + "cannot_connect": "No se puede conectar al puente", + "discover_timeout": "No se han descubierto puentes Philips Hue", + "no_bridges": "No se han descubierto puentes Philips Hue.", "unknown": "Se produjo un error desconocido" }, "error": { "linking": "Se produjo un error de enlace desconocido.", "register_failed": "No se pudo registrar, intente de nuevo" - } + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Elige el puente de Hue" + }, + "link": { + "description": "Presione el bot\u00f3n en el puente para registrar Philips Hue con Home Assistant. \n\n ! [Ubicaci\u00f3n del bot\u00f3n en el puente] (/static/images/config_philips_hue.jpg)", + "title": "Link Hub" + } + }, + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/cs.json b/homeassistant/components/ifttt/.translations/cs.json index abbbd9ff890..091ea9bc352 100644 --- a/homeassistant/components/ifttt/.translations/cs.json +++ b/homeassistant/components/ifttt/.translations/cs.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy IFTTT.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset pou\u017e\u00edt akci \"Vytvo\u0159it webovou \u017e\u00e1dost\" z [IFTTT Webhook appletu]({applet_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n - Typ obsahu: aplikace/json \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat." + }, "step": { "user": { "description": "Opravdu chcete nastavit IFTTT?", diff --git a/homeassistant/components/ifttt/.translations/es.json b/homeassistant/components/ifttt/.translations/es.json index 13240ccefb1..fc804bba46c 100644 --- a/homeassistant/components/ifttt/.translations/es.json +++ b/homeassistant/components/ifttt/.translations/es.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes IFTTT.", + "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, deber\u00e1 usar la acci\u00f3n \"Make a web request\" del [applet IFTTT Webhook] ( {applet_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application/json\n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + }, "step": { "user": { "description": "\u00bfEst\u00e1s seguro de que quieres configurar IFTTT?", diff --git a/homeassistant/components/ifttt/.translations/it.json b/homeassistant/components/ifttt/.translations/it.json new file mode 100644 index 00000000000..ac81f073347 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant deve essere accessibile da internet per ricevere messaggi IFTTT", + "one_instance_allowed": "E' necessaria una sola istanza." + }, + "create_entry": { + "default": "Per inviare eventi a Home Assistant, dovrai utilizzare l'azione \"Esegui una richiesta web\" dall'applet [Weblet di IFTTT] ( {applet_url} ). \n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Metodo: POST \n - Tipo di contenuto: application / json \n\n Vedi [la documentazione] ( {docs_url} ) su come configurare le automazioni per gestire i dati in arrivo." + }, + "step": { + "user": { + "description": "Sei sicuro di voler impostare IFTTT?", + "title": "Configura l'applet WebHook IFTTT" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/es.json b/homeassistant/components/ios/.translations/es.json new file mode 100644 index 00000000000..afd4fedc97e --- /dev/null +++ b/homeassistant/components/ios/.translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Solo se necesita una \u00fanica configuraci\u00f3n de Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "\u00bfDesea configurar el componente iOS de Home Assistant?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/it.json b/homeassistant/components/ios/.translations/it.json new file mode 100644 index 00000000000..3f587b7ee64 --- /dev/null +++ b/homeassistant/components/ios/.translations/it.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Home Assistant per iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/cs.json b/homeassistant/components/luftdaten/.translations/cs.json new file mode 100644 index 00000000000..701ccf2612c --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Nelze komunikovat s Luftdaten API", + "invalid_sensor": "Senzor nen\u00ed k dispozici nebo je neplatn\u00fd", + "sensor_exists": "Senzor je ji\u017e zaregistrov\u00e1n" + }, + "step": { + "user": { + "data": { + "show_on_map": "Uka\u017e na map\u011b", + "station_id": "ID senzoru Luftdaten" + }, + "title": "Definujte Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/es.json b/homeassistant/components/luftdaten/.translations/es.json new file mode 100644 index 00000000000..e93da557ae8 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "No se puede comunicar con la API de Luftdaten", + "invalid_sensor": "Sensor no disponible o no v\u00e1lido", + "sensor_exists": "Sensor ya registrado" + }, + "step": { + "user": { + "data": { + "show_on_map": "Mostrar en el mapa", + "station_id": "Sensro ID de Luftdaten" + }, + "title": "Definir Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/ko.json b/homeassistant/components/luftdaten/.translations/ko.json new file mode 100644 index 00000000000..7d182cc1a0e --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Luftdaten API \uc640 \ud1b5\uc2e0 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "invalid_sensor": "\uc13c\uc11c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uac70\ub098 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "sensor_exists": "\uc13c\uc11c\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "show_on_map": "\uc9c0\ub3c4\uc5d0 \ud45c\uc2dc\ud558\uae30", + "station_id": "Luftdaten \uc13c\uc11c ID" + }, + "title": "Luftdaten \uc124\uc815" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/lb.json b/homeassistant/components/luftdaten/.translations/lb.json new file mode 100644 index 00000000000..931d2a5557c --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Kann net mat der Luftdaten API kommuniz\u00e9ieren", + "invalid_sensor": "Sensor net disponibel oder ong\u00eblteg", + "sensor_exists": "Sensor ass scho registr\u00e9iert" + }, + "step": { + "user": { + "data": { + "show_on_map": "Op der Kaart uweisen", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Luftdaten d\u00e9fin\u00e9ieren" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/pl.json b/homeassistant/components/luftdaten/.translations/pl.json new file mode 100644 index 00000000000..5a2c30db44c --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z API Luftdaten", + "invalid_sensor": "Sensor niedost\u0119pny lub nieprawid\u0142owy", + "sensor_exists": "Sensor zosta\u0142 ju\u017c zarejestrowany" + }, + "step": { + "user": { + "data": { + "show_on_map": "Poka\u017c na mapie", + "station_id": "ID sensora Luftdaten" + }, + "title": "Konfiguracja Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/ru.json b/homeassistant/components/luftdaten/.translations/ru.json new file mode 100644 index 00000000000..506a5c05485 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a API Luftdaten", + "invalid_sensor": "\u0414\u0430\u0442\u0447\u0438\u043a \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u043b\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d", + "sensor_exists": "\u0414\u0430\u0442\u0447\u0438\u043a \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435", + "station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0434\u0430\u0442\u0447\u0438\u043a\u0430 Luftdaten" + }, + "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/sl.json b/homeassistant/components/luftdaten/.translations/sl.json new file mode 100644 index 00000000000..c1dd0462f94 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Ne morem komunicirati z Luftdaten API-jem", + "invalid_sensor": "Senzor ni na voljo ali je neveljaven", + "sensor_exists": "Senzor je \u017ee registriran" + }, + "step": { + "user": { + "data": { + "show_on_map": "Prika\u017ei na zemljevidu", + "station_id": "Luftdaten ID Senzorja" + }, + "title": "Dolo\u010dite Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/cs.json b/homeassistant/components/mailgun/.translations/cs.json new file mode 100644 index 00000000000..2f7c4e5a902 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Mailgun.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit [Webhooks with Mailgun]({mailgun_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: aplikace / json \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit slu\u017ebu Mailgun?", + "title": "Nastavit Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/es.json b/homeassistant/components/mailgun/.translations/es.json new file mode 100644 index 00000000000..4a10ff69b69 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Mailgun.", + "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [Webhooks en Mailgun] ( {mailgun_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application/json \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Mailgun?" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/cs.json b/homeassistant/components/mqtt/.translations/cs.json new file mode 100644 index 00000000000..e76577a5dc8 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Je povolena pouze jedin\u00e1 konfigurace MQTT." + }, + "error": { + "cannot_connect": "Nelze se p\u0159ipojit k brokeru." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "discovery": "Povolit automatick\u00e9 vyhled\u00e1v\u00e1n\u00ed za\u0159\u00edzen\u00ed", + "password": "Heslo", + "port": "Port", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "title": "MQTT" + }, + "hassio_confirm": { + "data": { + "discovery": "Povolit automatick\u00e9 vyhled\u00e1v\u00e1n\u00ed za\u0159\u00edzen\u00ed" + }, + "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k zprost\u0159edkovateli MQTT poskytovan\u00e9mu dopl\u0148kem hass.io {addon}?", + "title": "MQTT Broker prost\u0159ednictv\u00edm dopl\u0148ku Hass.io" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index 182cce86057..7af8f43b897 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -1,6 +1,23 @@ { "config": { + "abort": { + "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de MQTT." + }, + "error": { + "cannot_connect": "No se puede conectar con el agente" + }, "step": { + "broker": { + "data": { + "broker": "Agente", + "discovery": "Habilitar descubrimiento", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Usuario" + }, + "description": "Por favor, introduce la informaci\u00f3n de tu agente MQTT", + "title": "MQTT" + }, "hassio_confirm": { "data": { "discovery": "Habilitar descubrimiento" @@ -8,6 +25,7 @@ "description": "\u00bfDesea configurar Home Assistant para conectarse al agente MQTT provisto por el complemento hass.io {addon} ?", "title": "MQTT Broker a trav\u00e9s del complemento Hass.io" } - } + }, + "title": "MQTT" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/it.json b/homeassistant/components/mqtt/.translations/it.json new file mode 100644 index 00000000000..e56860cd675 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di MQTT." + }, + "error": { + "cannot_connect": "Impossibile connettersi al broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "discovery": "Attiva l'individuazione" + } + }, + "hassio_confirm": { + "data": { + "discovery": "Attiva l'individuazione" + } + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index 8b0ed27f3ae..7e35c219c45 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -10,7 +10,7 @@ "broker": { "data": { "broker": "\u0411\u0440\u043e\u043a\u0435\u0440", - "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435", + "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" @@ -20,7 +20,7 @@ }, "hassio_confirm": { "data": { - "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435" + "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432" }, "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Home Assistant \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443 MQTT \u0447\u0435\u0440\u0435\u0437 \u0430\u0434\u0434\u043e\u043d Hass.io {addon}?", "title": "\u0411\u0440\u043e\u043a\u0435\u0440 MQTT \u0447\u0435\u0440\u0435\u0437 \u0430\u0434\u0434\u043e\u043d Hass.io" diff --git a/homeassistant/components/nest/.translations/es.json b/homeassistant/components/nest/.translations/es.json index ceca4464e06..25af12a3bb8 100644 --- a/homeassistant/components/nest/.translations/es.json +++ b/homeassistant/components/nest/.translations/es.json @@ -1,10 +1,31 @@ { "config": { + "abort": { + "already_setup": "S\u00f3lo puedes configurar una \u00fanica cuenta de Nest.", + "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n", + "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.", + "no_flows": "Debe configurar Nest antes de poder autenticarse con \u00e9l. [Lea las instrucciones] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Error interno validando el c\u00f3digo", + "invalid_code": "C\u00f3digo inv\u00e1lido", + "timeout": "Tiempo de espera agotado validando el c\u00f3digo", + "unknown": "Error desconocido validando el c\u00f3digo" + }, "step": { + "init": { + "data": { + "flow_impl": "Proveedor" + }, + "description": "Elija a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n desea autenticarse con Nest.", + "title": "Proveedor de autenticaci\u00f3n" + }, "link": { "data": { "code": "C\u00f3digo PIN" - } + }, + "description": "Para vincular su cuenta de Nest, [autorice su cuenta] ( {url} ). \n\n Despu\u00e9s de la autorizaci\u00f3n, copie y pegue el c\u00f3digo pin provisto a continuaci\u00f3n.", + "title": "Vincular cuenta de Nest" } }, "title": "Nest" diff --git a/homeassistant/components/openuv/.translations/es.json b/homeassistant/components/openuv/.translations/es.json new file mode 100644 index 00000000000..03118f00ea6 --- /dev/null +++ b/homeassistant/components/openuv/.translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Coordenadas ya registradas", + "invalid_api_key": "Clave API inv\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API de OpenUV", + "elevation": "Elevaci\u00f3n", + "latitude": "Latitud", + "longitude": "Longitud" + }, + "title": "Completa tu informaci\u00f3n" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ko.json b/homeassistant/components/rainmachine/.translations/ko.json new file mode 100644 index 00000000000..0885c7e9e66 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "ip_address": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8" + }, + "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/lb.json b/homeassistant/components/rainmachine/.translations/lb.json new file mode 100644 index 00000000000..4456b105fbc --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto ass scho registr\u00e9iert", + "invalid_credentials": "Ong\u00eblteg Login Informatioune" + }, + "step": { + "user": { + "data": { + "ip_address": "Host Numm oder IP Adresse", + "password": "Passwuert", + "port": "Port" + }, + "title": "F\u00ebllt \u00e4r Informatiounen aus" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ru.json b/homeassistant/components/rainmachine/.translations/ru.json new file mode 100644 index 00000000000..4a714f18999 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430", + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435" + }, + "step": { + "user": { + "data": { + "ip_address": "\u0418\u043c\u044f \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0432\u043e\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.es.json b/homeassistant/components/sensor/.translations/moon.es.json index bbc03820b5b..3ce14cd4c77 100644 --- a/homeassistant/components/sensor/.translations/moon.es.json +++ b/homeassistant/components/sensor/.translations/moon.es.json @@ -2,6 +2,9 @@ "state": { "first_quarter": "Primer cuarto", "full_moon": "Luna llena", - "new_moon": "Luna nueva" + "last_quarter": "\u00daltimo cuarto", + "new_moon": "Luna nueva", + "waning_crescent": "Luna menguante", + "waxing_crescent": "Luna creciente" } } \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.fi.json b/homeassistant/components/sensor/.translations/moon.fi.json new file mode 100644 index 00000000000..10f8bb9b8a6 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.fi.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Ensimm\u00e4inen nelj\u00e4nnes", + "full_moon": "T\u00e4ysikuu", + "last_quarter": "Viimeinen nelj\u00e4nnes", + "new_moon": "Uusikuu", + "waning_crescent": "V\u00e4henev\u00e4 sirppi", + "waning_gibbous": "V\u00e4henev\u00e4 kuperakuu", + "waxing_crescent": "Kasvava sirppi", + "waxing_gibbous": "Kasvava kuperakuu" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.it.json b/homeassistant/components/sensor/.translations/moon.it.json index 014437e3fe4..f22a6d340ae 100644 --- a/homeassistant/components/sensor/.translations/moon.it.json +++ b/homeassistant/components/sensor/.translations/moon.it.json @@ -4,6 +4,7 @@ "full_moon": "Luna piena", "last_quarter": "Ultimo quarto", "new_moon": "Nuova luna", + "waning_crescent": "Luna calante", "waning_gibbous": "Gibbosa calante", "waxing_crescent": "Luna crescente", "waxing_gibbous": "Gibbosa crescente" diff --git a/homeassistant/components/simplisafe/.translations/cs.json b/homeassistant/components/simplisafe/.translations/cs.json index 0dd9912de0d..f4a47c5c344 100644 --- a/homeassistant/components/simplisafe/.translations/cs.json +++ b/homeassistant/components/simplisafe/.translations/cs.json @@ -13,6 +13,7 @@ }, "title": "Vypl\u0148te va\u0161e \u00fadaje" } - } + }, + "title": "SimpliSafe" } } \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/ko.json b/homeassistant/components/simplisafe/.translations/ko.json index eca099ed79d..5426c564e03 100644 --- a/homeassistant/components/simplisafe/.translations/ko.json +++ b/homeassistant/components/simplisafe/.translations/ko.json @@ -2,7 +2,7 @@ "config": { "error": { "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "invalid_credentials": "\uc774\uba54\uc77c \uc8fc\uc18c \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/sonos/.translations/es.json b/homeassistant/components/sonos/.translations/es.json index c91f9a78c29..d2372a7d9b7 100644 --- a/homeassistant/components/sonos/.translations/es.json +++ b/homeassistant/components/sonos/.translations/es.json @@ -1,7 +1,12 @@ { "config": { + "abort": { + "no_devices_found": "No se han encontrado dispositivos Sonos en la red.", + "single_instance_allowed": "S\u00f3lo se necesita una \u00fanica configuraci\u00f3n de Sonos." + }, "step": { "confirm": { + "description": "\u00bfQuieres configurar Sonos?", "title": "Sonos" } }, diff --git a/homeassistant/components/tradfri/.translations/cs.json b/homeassistant/components/tradfri/.translations/cs.json new file mode 100644 index 00000000000..97a0e25d754 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/cs.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge je ji\u017e nakonfigurov\u00e1n" + }, + "error": { + "cannot_connect": "Nelze se p\u0159ipojit k br\u00e1n\u011b.", + "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el" + }, + "step": { + "auth": { + "data": { + "host": "Hostitel", + "security_code": "Bezpe\u010dnostn\u00ed k\u00f3d" + }, + "description": "Bezpe\u010dnostn\u00ed k\u00f3d naleznete na zadn\u00ed stran\u011b za\u0159\u00edzen\u00ed.", + "title": "Zadejte bezpe\u010dnostn\u00ed k\u00f3d" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/es.json b/homeassistant/components/tradfri/.translations/es.json new file mode 100644 index 00000000000..991832c9053 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "El puente ya esta configurado" + }, + "error": { + "cannot_connect": "No se puede conectar a la puerta de enlace.", + "invalid_key": "No se pudo registrar con la clave proporcionada. Si esto sigue sucediendo, intente reiniciar la puerta de enlace.", + "timeout": "Tiempo de espera agotado validando el c\u00f3digo." + }, + "step": { + "auth": { + "data": { + "host": "Host", + "security_code": "C\u00f3digo de seguridad" + }, + "description": "Puede encontrar el c\u00f3digo de seguridad en la parte posterior de su puerta de enlace.", + "title": "Introduzca el c\u00f3digo de seguridad" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/it.json b/homeassistant/components/tradfri/.translations/it.json new file mode 100644 index 00000000000..3d5101bbce8 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/it.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/cs.json b/homeassistant/components/twilio/.translations/cs.json new file mode 100644 index 00000000000..d484ede413e --- /dev/null +++ b/homeassistant/components/twilio/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Twilio.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, mus\u00edte nastavit [Webhooks s Twilio]({twilio_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: application/x-www-form-urlencoded \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit slu\u017ebu Twilio?", + "title": "Nastaven\u00ed Twilio Webhook" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/es.json b/homeassistant/components/twilio/.translations/es.json new file mode 100644 index 00000000000..7927ce63e7f --- /dev/null +++ b/homeassistant/components/twilio/.translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Twilio.", + "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [Webhooks en Twilio] ( {twilio_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application / x-www-form-urlencoded \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Twilio?" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/cs.json b/homeassistant/components/unifi/.translations/cs.json new file mode 100644 index 00000000000..95ba46597da --- /dev/null +++ b/homeassistant/components/unifi/.translations/cs.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "user_privilege": "U\u017eivatel mus\u00ed b\u00fdt spr\u00e1vcem" + }, + "error": { + "faulty_credentials": "Chybn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje", + "service_unavailable": "Slu\u017eba nen\u00ed dostupn\u00e1" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "password": "Heslo", + "port": "Port", + "site": "ID s\u00edt\u011b", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no", + "verify_ssl": "\u0158adi\u010d pou\u017e\u00edv\u00e1 spr\u00e1vn\u00fd certifik\u00e1t" + }, + "title": "Nastaven\u00ed UniFi \u0159adi\u010de" + } + }, + "title": "UniFi \u0159adi\u010d" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/cs.json b/homeassistant/components/upnp/.translations/cs.json index 24a725d1af6..58de4963000 100644 --- a/homeassistant/components/upnp/.translations/cs.json +++ b/homeassistant/components/upnp/.translations/cs.json @@ -1,8 +1,19 @@ { "config": { + "abort": { + "already_configured": "UPnP/IGD je ji\u017e nakonfigurov\u00e1no", + "incomplete_device": "Ignorov\u00e1n\u00ed ne\u00fapln\u00e9ho za\u0159\u00edzen\u00ed UPnP", + "no_devices_discovered": "Nebyly zji\u0161t\u011bny \u017e\u00e1dn\u00e9 UPnP/IGD", + "no_sensors_or_port_mapping": "Povolte senzory nebo mapov\u00e1n\u00ed port\u016f" + }, "step": { + "init": { + "title": "UPnP/IGD" + }, "user": { "data": { + "enable_port_mapping": "Povolit mapov\u00e1n\u00ed port\u016f pro Home Assistant", + "enable_sensors": "P\u0159idejte dopravn\u00ed senzory", "igd": "UPnP/IGD" }, "title": "Mo\u017enosti konfigurace pro UPnP/IGD" diff --git a/homeassistant/components/upnp/.translations/es.json b/homeassistant/components/upnp/.translations/es.json index e4cabf4cd50..652ff87d9d4 100644 --- a/homeassistant/components/upnp/.translations/es.json +++ b/homeassistant/components/upnp/.translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP / IGD ya est\u00e1 configurado", + "incomplete_device": "Ignorando el dispositivo UPnP incompleto", "no_devices_discovered": "No se descubrieron UPnP / IGDs", "no_sensors_or_port_mapping": "Habilitar al menos sensores o mapeo de puertos" }, diff --git a/homeassistant/components/zone/.translations/es.json b/homeassistant/components/zone/.translations/es.json new file mode 100644 index 00000000000..7a0f6c967c2 --- /dev/null +++ b/homeassistant/components/zone/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "El nombre ya existe" + }, + "step": { + "init": { + "data": { + "icon": "Icono", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre", + "passive": "Pasivo", + "radius": "Radio" + }, + "title": "Definir par\u00e1metros de la zona" + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/es.json b/homeassistant/components/zwave/.translations/es.json index 8c287d9a539..39947080d18 100644 --- a/homeassistant/components/zwave/.translations/es.json +++ b/homeassistant/components/zwave/.translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Z-Wave ya est\u00e1 configurado", "one_instance_only": "El componente solo admite una instancia de Z-Wave" }, "error": { From d1b5bc19dac8891396b23426b02ca9f88710ad8a Mon Sep 17 00:00:00 2001 From: Oleksii Serdiuk Date: Thu, 15 Nov 2018 18:23:46 +0100 Subject: [PATCH 161/238] AirVisual: Show icon for air pollution level, based on its value (#18482) Show excited, happy, neutral, sad, dead emoticon, or biohazard icon, based on air pollution level. Also, fix a small typo and change air quality index icon to `mdi:chart-line`. Seems a bit more logical. --- homeassistant/components/sensor/airvisual.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 6837b9e1b2f..ff99dce5e06 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -41,33 +41,39 @@ SENSOR_TYPE_LEVEL = 'air_pollution_level' SENSOR_TYPE_AQI = 'air_quality_index' SENSOR_TYPE_POLLUTANT = 'main_pollutant' SENSORS = [ - (SENSOR_TYPE_LEVEL, 'Air Pollution Level', 'mdi:scale', None), - (SENSOR_TYPE_AQI, 'Air Quality Index', 'mdi:format-list-numbers', 'AQI'), + (SENSOR_TYPE_LEVEL, 'Air Pollution Level', 'mdi:gauge', None), + (SENSOR_TYPE_AQI, 'Air Quality Index', 'mdi:chart-line', 'AQI'), (SENSOR_TYPE_POLLUTANT, 'Main Pollutant', 'mdi:chemical-weapon', None), ] POLLUTANT_LEVEL_MAPPING = [{ 'label': 'Good', + 'icon': 'mdi:emoticon-excited', 'minimum': 0, 'maximum': 50 }, { 'label': 'Moderate', + 'icon': 'mdi:emoticon-happy', 'minimum': 51, 'maximum': 100 }, { - 'label': 'Unhealthy for sensitive group', + 'label': 'Unhealthy for sensitive groups', + 'icon': 'mdi:emoticon-neutral', 'minimum': 101, 'maximum': 150 }, { 'label': 'Unhealthy', + 'icon': 'mdi:emoticon-sad', 'minimum': 151, 'maximum': 200 }, { 'label': 'Very Unhealthy', + 'icon': 'mdi:emoticon-dead', 'minimum': 201, 'maximum': 300 }, { 'label': 'Hazardous', + 'icon': 'mdi:biohazard', 'minimum': 301, 'maximum': 10000 }] @@ -237,6 +243,7 @@ class AirVisualSensor(Entity): if i['minimum'] <= aqi <= i['maximum'] ] self._state = level['label'] + self._icon = level['icon'] elif self._type == SENSOR_TYPE_AQI: self._state = data['aqi{0}'.format(self._locale)] elif self._type == SENSOR_TYPE_POLLUTANT: From 4446b15cb031854407ac43eacc84650f139cd660 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 15 Nov 2018 10:43:20 -0700 Subject: [PATCH 162/238] Add Rainmachine to the device registry (#18452) * Device registry in * Member comments * Hound --- homeassistant/components/rainmachine/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 5778d9db4df..7017f80a50f 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -271,6 +271,21 @@ class RainMachineEntity(Entity): self._name = None self.rainmachine = rainmachine + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + 'identifiers': { + (DOMAIN, self.rainmachine.client.mac) + }, + 'name': self.rainmachine.client.name, + 'manufacturer': 'RainMachine', + 'model': 'Version {0} (API: {1})'.format( + self.rainmachine.client.hardware_version, + self.rainmachine.client.api_version), + 'sw_version': self.rainmachine.client.software_version, + } + @property def device_state_attributes(self) -> dict: """Return the state attributes.""" From afb3a52b5be7fc492ea3880cac519a826127d2f8 Mon Sep 17 00:00:00 2001 From: Leothlon Date: Thu, 15 Nov 2018 18:49:10 +0100 Subject: [PATCH 163/238] Fixed bug for receivers without support for new command (#18478) * Fixed bug for receivers without support for new command * removed extra parenthesis --- homeassistant/components/media_player/onkyo.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 367ad2aa972..5ff54201b3c 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -220,6 +220,9 @@ class OnkyoDevice(MediaPlayerDevice): [i for i in current_source_tuples[1]]) self._muted = bool(mute_raw[1] == 'on') self._volume = volume_raw[1] / self._max_volume + + if not hdmi_out_raw: + return self._attributes["video_out"] = ','.join(hdmi_out_raw[1]) @property From b7b8296c73b28634bb9c60efe1ad976b1092fce8 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Thu, 15 Nov 2018 19:15:50 +0100 Subject: [PATCH 164/238] Alexa v3 name change for additionalApplianceDetails (#18485) --- homeassistant/components/alexa/smart_home.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 9496bf6804c..2a61533a2b9 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1233,7 +1233,7 @@ async def async_api_discovery(hass, config, directive, context): endpoint = { 'displayCategories': alexa_entity.display_categories(), - 'additionalApplianceDetails': {}, + 'cookie': {}, 'endpointId': alexa_entity.entity_id(), 'friendlyName': alexa_entity.friendly_name(), 'description': alexa_entity.description(), From ed7aea006a0af66526765a875552f7a1ec55fbbe Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 16 Nov 2018 11:08:39 +0100 Subject: [PATCH 165/238] Add safe_mode HomeKit (#18356) * Use: 'safe_mode: True' in case of pairing issues --- homeassistant/components/homekit/__init__.py | 13 +++++-- homeassistant/components/homekit/const.py | 2 + tests/components/homekit/test_homekit.py | 39 ++++++++++++++------ 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 650fff45c7b..cd517b6da7c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -22,7 +22,8 @@ from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry from .const import ( BRIDGE_NAME, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST, - CONF_FILTER, DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO, + CONF_FILTER, CONF_SAFE_MODE, DEFAULT_AUTO_START, DEFAULT_PORT, + DEFAULT_SAFE_MODE, DEVICE_CLASS_CO, DEVICE_CLASS_CO2, DEVICE_CLASS_PM25, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE) @@ -58,6 +59,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean, + vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean, vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, }) @@ -73,11 +75,12 @@ async def async_setup(hass, config): port = conf[CONF_PORT] ip_address = conf.get(CONF_IP_ADDRESS) auto_start = conf[CONF_AUTO_START] + safe_mode = conf[CONF_SAFE_MODE] entity_filter = conf[CONF_FILTER] entity_config = conf[CONF_ENTITY_CONFIG] homekit = HomeKit(hass, name, port, ip_address, entity_filter, - entity_config) + entity_config, safe_mode) await hass.async_add_executor_job(homekit.setup) if auto_start: @@ -196,7 +199,7 @@ class HomeKit(): """Class to handle all actions between HomeKit and Home Assistant.""" def __init__(self, hass, name, port, ip_address, entity_filter, - entity_config): + entity_config, safe_mode): """Initialize a HomeKit object.""" self.hass = hass self._name = name @@ -204,6 +207,7 @@ class HomeKit(): self._ip_address = ip_address self._filter = entity_filter self._config = entity_config + self._safe_mode = safe_mode self.status = STATUS_READY self.bridge = None @@ -221,6 +225,9 @@ class HomeKit(): self.driver = HomeDriver(self.hass, address=ip_addr, port=self._port, persist_file=path) self.bridge = HomeBridge(self.hass, self.driver, self._name) + if self._safe_mode: + _LOGGER.debug('Safe_mode selected') + self.driver.safe_mode = True def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 0b4cdf15fb5..d0e3d52b363 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -15,10 +15,12 @@ CONF_ENTITY_CONFIG = 'entity_config' CONF_FEATURE = 'feature' CONF_FEATURE_LIST = 'feature_list' CONF_FILTER = 'filter' +CONF_SAFE_MODE = 'safe_mode' # #### Config Defaults #### DEFAULT_AUTO_START = True DEFAULT_PORT = 51827 +DEFAULT_SAFE_MODE = False # #### Features #### FEATURE_ON_OFF = 'on_off' diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index a831a7e9e5d..4dbb6351ee7 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -9,8 +9,8 @@ from homeassistant.components.homekit import ( STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT) from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( - CONF_AUTO_START, BRIDGE_NAME, DEFAULT_PORT, DOMAIN, HOMEKIT_FILE, - SERVICE_HOMEKIT_START) + CONF_AUTO_START, CONF_SAFE_MODE, BRIDGE_NAME, DEFAULT_PORT, + DEFAULT_SAFE_MODE, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START) from homeassistant.const import ( CONF_NAME, CONF_IP_ADDRESS, CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) @@ -49,7 +49,7 @@ async def test_setup_min(hass): hass, DOMAIN, {DOMAIN: {}}) mock_homekit.assert_any_call(hass, BRIDGE_NAME, DEFAULT_PORT, None, ANY, - {}) + {}, DEFAULT_SAFE_MODE) assert mock_homekit().setup.called is True # Test auto start enabled @@ -63,7 +63,8 @@ async def test_setup_min(hass): async def test_setup_auto_start_disabled(hass): """Test async_setup with auto start disabled and test service calls.""" config = {DOMAIN: {CONF_AUTO_START: False, CONF_NAME: 'Test Name', - CONF_PORT: 11111, CONF_IP_ADDRESS: '172.0.0.0'}} + CONF_PORT: 11111, CONF_IP_ADDRESS: '172.0.0.0', + CONF_SAFE_MODE: DEFAULT_SAFE_MODE}} with patch(PATH_HOMEKIT + '.HomeKit') as mock_homekit: mock_homekit.return_value = homekit = Mock() @@ -71,7 +72,7 @@ async def test_setup_auto_start_disabled(hass): hass, DOMAIN, config) mock_homekit.assert_any_call(hass, 'Test Name', 11111, '172.0.0.0', ANY, - {}) + {}, DEFAULT_SAFE_MODE) assert mock_homekit().setup.called is True # Test auto_start disabled @@ -99,7 +100,8 @@ async def test_setup_auto_start_disabled(hass): async def test_homekit_setup(hass, hk_driver): """Test setup of bridge and driver.""" - homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}) + homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, + DEFAULT_SAFE_MODE) with patch(PATH_HOMEKIT + '.accessories.HomeDriver', return_value=hk_driver) as mock_driver, \ @@ -111,6 +113,7 @@ async def test_homekit_setup(hass, hk_driver): assert isinstance(homekit.bridge, HomeBridge) mock_driver.assert_called_with( hass, address=IP_ADDRESS, port=DEFAULT_PORT, persist_file=path) + assert homekit.driver.safe_mode is False # Test if stop listener is setup assert hass.bus.async_listeners().get(EVENT_HOMEASSISTANT_STOP) == 1 @@ -118,7 +121,8 @@ async def test_homekit_setup(hass, hk_driver): async def test_homekit_setup_ip_address(hass, hk_driver): """Test setup with given IP address.""" - homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, '172.0.0.0', {}, {}) + homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, '172.0.0.0', {}, {}, + None) with patch(PATH_HOMEKIT + '.accessories.HomeDriver', return_value=hk_driver) as mock_driver: @@ -127,9 +131,20 @@ async def test_homekit_setup_ip_address(hass, hk_driver): hass, address='172.0.0.0', port=DEFAULT_PORT, persist_file=ANY) +async def test_homekit_setup_safe_mode(hass, hk_driver): + """Test if safe_mode flag is set.""" + homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, True) + + with patch(PATH_HOMEKIT + '.accessories.HomeDriver', + return_value=hk_driver): + await hass.async_add_job(homekit.setup) + assert homekit.driver.safe_mode is True + + async def test_homekit_add_accessory(): """Add accessory if config exists and get_acc returns an accessory.""" - homekit = HomeKit('hass', None, None, None, lambda entity_id: True, {}) + homekit = HomeKit('hass', None, None, None, lambda entity_id: True, {}, + None) homekit.driver = 'driver' homekit.bridge = mock_bridge = Mock() @@ -152,7 +167,7 @@ async def test_homekit_add_accessory(): async def test_homekit_entity_filter(hass): """Test the entity filter.""" entity_filter = generate_filter(['cover'], ['demo.test'], [], []) - homekit = HomeKit(hass, None, None, None, entity_filter, {}) + homekit = HomeKit(hass, None, None, None, entity_filter, {}, None) with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: mock_get_acc.return_value = None @@ -172,7 +187,7 @@ async def test_homekit_entity_filter(hass): async def test_homekit_start(hass, hk_driver, debounce_patcher): """Test HomeKit start method.""" pin = b'123-45-678' - homekit = HomeKit(hass, None, None, None, {}, {'cover.demo': {}}) + homekit = HomeKit(hass, None, None, None, {}, {'cover.demo': {}}, None) homekit.bridge = Mock() homekit.bridge.accessories = [] homekit.driver = hk_driver @@ -203,7 +218,7 @@ async def test_homekit_start(hass, hk_driver, debounce_patcher): async def test_homekit_stop(hass): """Test HomeKit stop method.""" - homekit = HomeKit(hass, None, None, None, None, None) + homekit = HomeKit(hass, None, None, None, None, None, None) homekit.driver = Mock() assert homekit.status == STATUS_READY @@ -222,7 +237,7 @@ async def test_homekit_stop(hass): async def test_homekit_too_many_accessories(hass, hk_driver): """Test adding too many accessories to HomeKit.""" - homekit = HomeKit(hass, None, None, None, None, None) + homekit = HomeKit(hass, None, None, None, None, None, None) homekit.bridge = Mock() homekit.bridge.accessories = range(MAX_DEVICES + 1) homekit.driver = hk_driver From 0ca94f239ddc2bac966cfc1b694fba188c79ac29 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 16 Nov 2018 11:42:48 +0100 Subject: [PATCH 166/238] Update HAP-python 2.4.1 (#18417) * Bugfixes --- homeassistant/components/homekit/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index cd517b6da7c..da8daf50f2a 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -30,7 +30,7 @@ from .const import ( from .util import ( show_setup_message, validate_entity_config, validate_media_player_features) -REQUIREMENTS = ['HAP-python==2.4.0'] +REQUIREMENTS = ['HAP-python==2.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 1545440f8b7..09456c05420 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -32,7 +32,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.0.0 # homeassistant.components.homekit -HAP-python==2.4.0 +HAP-python==2.4.1 # homeassistant.components.notify.mastodon Mastodon.py==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49cc4dd1102..68ef6987e3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ requests_mock==1.5.2 # homeassistant.components.homekit -HAP-python==2.4.0 +HAP-python==2.4.1 # homeassistant.components.sensor.rmvtransport PyRMVtransport==0.1.3 From 9f51deb1dea1d35d8ae2da87caa4ce47080d32c7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 16 Nov 2018 14:14:20 +0100 Subject: [PATCH 167/238] Upgrade youtube_dl to 2018.11.07 (#18507) --- 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 858f95e7ae4..de60f7eee93 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.10.29'] +REQUIREMENTS = ['youtube_dl==2018.11.07'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 09456c05420..7a3d5b5bcb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1634,7 +1634,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.10.29 +youtube_dl==2018.11.07 # homeassistant.components.light.zengge zengge==0.2 From 30806fa3623a2f2e099357771d9283268c2e9516 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 16 Nov 2018 14:14:40 +0100 Subject: [PATCH 168/238] Upgrade numpy to 1.15.4 (#18506) --- .../components/binary_sensor/trend.py | 2 +- .../components/image_processing/opencv.py | 2 +- .../components/image_processing/tensorflow.py | 61 +++++++++---------- homeassistant/components/sensor/pollen.py | 11 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 39 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 08838be3ea6..4773e88f5df 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import async_track_state_change from homeassistant.util import utcnow -REQUIREMENTS = ['numpy==1.15.3'] +REQUIREMENTS = ['numpy==1.15.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index e44ae6e1ae3..7694dbd6735 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing import ( from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.3'] +REQUIREMENTS = ['numpy==1.15.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index 2d06dbbcf34..8f5b599bb88 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -1,5 +1,5 @@ """ -Component that performs TensorFlow classification on images. +Support for performing TensorFlow classification on images. For a quick start, pick a pre-trained COCO model from: https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/detection_model_zoo.md @@ -8,8 +8,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.tensorflow/ """ import logging -import sys import os +import sys import voluptuous as vol @@ -20,7 +20,7 @@ from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.3', 'pillow==5.2.0', 'protobuf==3.6.1'] +REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.2.0', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) @@ -28,29 +28,29 @@ ATTR_MATCHES = 'matches' ATTR_SUMMARY = 'summary' ATTR_TOTAL_MATCHES = 'total_matches' -CONF_FILE_OUT = 'file_out' -CONF_MODEL = 'model' -CONF_GRAPH = 'graph' -CONF_LABELS = 'labels' -CONF_MODEL_DIR = 'model_dir' +CONF_AREA = 'area' +CONF_BOTTOM = 'bottom' CONF_CATEGORIES = 'categories' CONF_CATEGORY = 'category' -CONF_AREA = 'area' -CONF_TOP = 'top' +CONF_FILE_OUT = 'file_out' +CONF_GRAPH = 'graph' +CONF_LABELS = 'labels' CONF_LEFT = 'left' -CONF_BOTTOM = 'bottom' +CONF_MODEL = 'model' +CONF_MODEL_DIR = 'model_dir' CONF_RIGHT = 'right' +CONF_TOP = 'top' AREA_SCHEMA = vol.Schema({ - vol.Optional(CONF_TOP, default=0): cv.small_float, - vol.Optional(CONF_LEFT, default=0): cv.small_float, vol.Optional(CONF_BOTTOM, default=1): cv.small_float, - vol.Optional(CONF_RIGHT, default=1): cv.small_float + vol.Optional(CONF_LEFT, default=0): cv.small_float, + vol.Optional(CONF_RIGHT, default=1): cv.small_float, + vol.Optional(CONF_TOP, default=0): cv.small_float, }) CATEGORY_SCHEMA = vol.Schema({ vol.Required(CONF_CATEGORY): cv.string, - vol.Optional(CONF_AREA): AREA_SCHEMA + vol.Optional(CONF_AREA): AREA_SCHEMA, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -58,14 +58,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [cv.template]), vol.Required(CONF_MODEL): vol.Schema({ vol.Required(CONF_GRAPH): cv.isfile, - vol.Optional(CONF_LABELS): cv.isfile, - vol.Optional(CONF_MODEL_DIR): cv.isdir, vol.Optional(CONF_AREA): AREA_SCHEMA, vol.Optional(CONF_CATEGORIES, default=[]): - vol.All(cv.ensure_list, [vol.Any( - cv.string, - CATEGORY_SCHEMA - )]) + vol.All(cv.ensure_list, [vol.Any(cv.string, CATEGORY_SCHEMA)]), + vol.Optional(CONF_LABELS): cv.isfile, + vol.Optional(CONF_MODEL_DIR): cv.isdir, }) }) @@ -93,7 +90,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Make sure locations exist if not os.path.isdir(model_dir) or not os.path.exists(labels): - _LOGGER.error("Unable to locate tensorflow models or label map.") + _LOGGER.error("Unable to locate tensorflow models or label map") return # append custom model path to sys.path @@ -118,9 +115,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # pylint: disable=unused-import,unused-variable import cv2 # noqa except ImportError: - _LOGGER.warning("No OpenCV library found. " - "TensorFlow will process image with " - "PIL at reduced resolution.") + _LOGGER.warning( + "No OpenCV library found. TensorFlow will process image with " + "PIL at reduced resolution") # setup tensorflow graph, session, and label map to pass to processor # pylint: disable=no-member @@ -241,23 +238,23 @@ class TensorFlowImageProcessor(ImageProcessingEntity): # Draw custom global region/area if self._area != [0, 0, 1, 1]: draw_box(draw, self._area, - img_width, img_height, - "Detection Area", (0, 255, 255)) + img_width, img_height, "Detection Area", (0, 255, 255)) for category, values in matches.items(): # Draw custom category regions/areas if (category in self._category_areas and self._category_areas[category] != [0, 0, 1, 1]): label = "{} Detection Area".format(category.capitalize()) - draw_box(draw, self._category_areas[category], img_width, - img_height, label, (0, 255, 0)) + draw_box( + draw, self._category_areas[category], img_width, + img_height, label, (0, 255, 0)) # Draw detected objects for instance in values: label = "{0} {1:.1f}%".format(category, instance['score']) - draw_box(draw, instance['box'], - img_width, img_height, - label, (255, 255, 0)) + draw_box( + draw, instance['box'], img_width, img_height, label, + (255, 255, 0)) for path in paths: _LOGGER.info("Saving results image to %s", path) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 7bbdd1ee0b5..c5acad837bc 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -4,21 +4,22 @@ Support for Pollen.com allergen and cold/flu sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.pollen/ """ -import logging from datetime import timedelta +import logging from statistics import mean import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS) from homeassistant.helpers import aiohttp_client +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['numpy==1.15.3', 'pypollencom==2.2.2'] +REQUIREMENTS = ['numpy==1.15.4', 'pypollencom==2.2.2'] + _LOGGER = logging.getLogger(__name__) ATTR_ALLERGEN_AMOUNT = 'allergen_amount' @@ -401,8 +402,8 @@ class PollenComData: await self._get_data( self._client.disease.extended, TYPE_DISEASE_FORECAST) - _LOGGER.debug('New data retrieved: %s', self.data) + _LOGGER.debug("New data retrieved: %s", self.data) except InvalidZipError: _LOGGER.error( - 'Cannot retrieve data for ZIP code: %s', self._client.zip_code) + "Cannot retrieve data for ZIP code: %s", self._client.zip_code) self.data = {} diff --git a/requirements_all.txt b/requirements_all.txt index 7a3d5b5bcb2..9acc4698674 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ nuheat==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.15.3 +numpy==1.15.4 # homeassistant.components.google oauth2client==4.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68ef6987e3a..1697e0fdc0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -123,7 +123,7 @@ mficlient==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.15.3 +numpy==1.15.4 # homeassistant.components.mqtt # homeassistant.components.shiftr From 9e0497875e13392e53081c2574f8397f1692e16b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 16 Nov 2018 14:15:04 +0100 Subject: [PATCH 169/238] Upgrade sphinx-autodoc-typehints to 1.5.0 (#18505) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index cd2eb1a0be6..16c861a75fc 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ Sphinx==1.8.1 -sphinx-autodoc-typehints==1.3.0 +sphinx-autodoc-typehints==1.5.0 sphinx-autodoc-annotation==1.0.post1 From 320efdb7446995392e616c49a67ceb1e48e12d55 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 16 Nov 2018 14:15:53 +0100 Subject: [PATCH 170/238] Upgrade sqlalchemy to 1.2.14 (#18504) --- homeassistant/components/recorder/__init__.py | 2 +- homeassistant/components/sensor/sql.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index bc624ca5f89..ddb508d1282 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -34,7 +34,7 @@ from . import migration, purge from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.13'] +REQUIREMENTS = ['sqlalchemy==1.2.14'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index fd12ea18088..b3ce8fc28a0 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.13'] +REQUIREMENTS = ['sqlalchemy==1.2.14'] CONF_COLUMN_NAME = 'column' CONF_QUERIES = 'queries' diff --git a/requirements_all.txt b/requirements_all.txt index 9acc4698674..cc94541ee5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1454,7 +1454,7 @@ spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.recorder # homeassistant.components.sensor.sql -sqlalchemy==1.2.13 +sqlalchemy==1.2.14 # homeassistant.components.sensor.srp_energy srpenergy==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1697e0fdc0a..6ac42d90948 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -239,7 +239,7 @@ somecomfort==0.5.2 # homeassistant.components.recorder # homeassistant.components.sensor.sql -sqlalchemy==1.2.13 +sqlalchemy==1.2.14 # homeassistant.components.sensor.srp_energy srpenergy==1.0.5 From 24db2b66ab430cfe2b1e92138885599855261e46 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 16 Nov 2018 14:28:39 +0100 Subject: [PATCH 171/238] Ban systemair-savecair (#18483) --- homeassistant/package_constraints.txt | 3 +++ script/gen_requirements_all.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 578cd915fcd..d31e3523f48 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,3 +22,6 @@ enum34==1000000000.0.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 + +# Contains code to modify Home Assistant to work around our rules +python-systemair-savecair==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index cf5791ef38d..76a9e05de33 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -142,6 +142,9 @@ enum34==1000000000.0.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 + +# Contains code to modify Home Assistant to work around our rules +python-systemair-savecair==1000000000.0.0 """ From fcd756d58a56e07adb951d25456b3718c93103e9 Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Fri, 16 Nov 2018 20:14:46 +0100 Subject: [PATCH 172/238] fixed sky_hub schema to reflect documentation (#18509) --- homeassistant/components/device_tracker/sky_hub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/sky_hub.py b/homeassistant/components/device_tracker/sky_hub.py index deab486ec6e..0d69e08aa71 100644 --- a/homeassistant/components/device_tracker/sky_hub.py +++ b/homeassistant/components/device_tracker/sky_hub.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string + vol.Optional(CONF_HOST): cv.string }) From 6b2f50b29e985cb1bf6903afc8a7b60ef4329361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 16 Nov 2018 20:19:54 +0100 Subject: [PATCH 173/238] Improve real time consumption for Tibber sensor (#18517) --- homeassistant/components/tibber/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 8e824c0e2c2..2545417e033 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.7.5'] +REQUIREMENTS = ['pyTibber==0.8.2'] DOMAIN = 'tibber' diff --git a/requirements_all.txt b/requirements_all.txt index cc94541ee5b..19466f0236e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -818,7 +818,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.3 # homeassistant.components.tibber -pyTibber==0.7.5 +pyTibber==0.8.2 # homeassistant.components.switch.dlink pyW215==0.6.0 From e73b9b9b8f62372382c77dd2a44a5c4bb01498f6 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 17 Nov 2018 02:42:50 -0700 Subject: [PATCH 174/238] Clean up left-behind dispatcher handlers when removing RainMachine (#18488) * Clean up left-behind dispatcher handlers when removing RainMachine * Member comments --- .../components/binary_sensor/rainmachine.py | 9 ++------- .../components/rainmachine/__init__.py | 7 ++++++- homeassistant/components/sensor/rainmachine.py | 9 ++------- homeassistant/components/switch/rainmachine.py | 17 ++++++----------- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py index 88b2dd22d52..4a671fc9512 100644 --- a/homeassistant/components/binary_sensor/rainmachine.py +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -78,13 +78,8 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): """Update the state.""" self.async_schedule_update_ha_state(True) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SENSOR_UPDATE_TOPIC, update) - - async def async_will_remove_from_hass(self): - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, SENSOR_UPDATE_TOPIC, update)) async def async_update(self): """Update the state.""" diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 7017f80a50f..928c2ab2027 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -267,7 +267,7 @@ class RainMachineEntity(Entity): def __init__(self, rainmachine): """Initialize.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._async_unsub_dispatcher_connect = None + self._dispatcher_handlers = [] self._name = None self.rainmachine = rainmachine @@ -295,3 +295,8 @@ class RainMachineEntity(Entity): def name(self) -> str: """Return the name of the entity.""" return self._name + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + for handler in self._dispatcher_handlers: + handler() diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/sensor/rainmachine.py index 59efd4c47f6..5131b25510a 100644 --- a/homeassistant/components/sensor/rainmachine.py +++ b/homeassistant/components/sensor/rainmachine.py @@ -81,13 +81,8 @@ class RainMachineSensor(RainMachineEntity): """Update the state.""" self.async_schedule_update_ha_state(True) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SENSOR_UPDATE_TOPIC, update) - - async def async_will_remove_from_hass(self): - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, SENSOR_UPDATE_TOPIC, update)) async def async_update(self): """Update the sensor's state.""" diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 5d03b2691eb..b48cc0a1e14 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -186,13 +186,8 @@ class RainMachineProgram(RainMachineSwitch): async def async_added_to_hass(self): """Register callbacks.""" - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated) - - async def async_will_remove_from_hass(self): - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated)) async def async_turn_off(self, **kwargs) -> None: """Turn the program off.""" @@ -256,10 +251,10 @@ class RainMachineZone(RainMachineSwitch): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated) - async_dispatcher_connect( - self.hass, ZONE_UPDATE_TOPIC, self._program_updated) + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated)) + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, ZONE_UPDATE_TOPIC, self._program_updated)) async def async_turn_off(self, **kwargs) -> None: """Turn the zone off.""" From c6ca27e9b4ac7549b03f20e02fc8459b1ac0f7ba Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 17 Nov 2018 13:18:51 +0100 Subject: [PATCH 175/238] Improve handling of unavailable Sonos speakers (#18534) --- .../components/media_player/sonos.py | 42 ++++++++++++------- homeassistant/components/sonos/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 28ff269f400..b34aabd4c51 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -11,6 +11,7 @@ import socket import threading import urllib +import requests import voluptuous as vol from homeassistant.components.media_player import ( @@ -30,6 +31,8 @@ DEPENDENCIES = ('sonos',) _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + # Quiet down pysonos logging to just actual problems. logging.getLogger('pysonos').setLevel(logging.WARNING) logging.getLogger('pysonos.data_structures_entry').setLevel(logging.ERROR) @@ -334,6 +337,7 @@ class SonosDevice(MediaPlayerDevice): def __init__(self, player): """Initialize the Sonos device.""" + self._subscriptions = [] self._receives_events = False self._volume_increment = 2 self._unique_id = player.uid @@ -481,17 +485,16 @@ class SonosDevice(MediaPlayerDevice): player = self.soco - queue = _ProcessSonosEventQueue(self.update_media) - player.avTransport.subscribe(auto_renew=True, event_queue=queue) + def subscribe(service, action): + """Add a subscription to a pysonos service.""" + queue = _ProcessSonosEventQueue(action) + sub = service.subscribe(auto_renew=True, event_queue=queue) + self._subscriptions.append(sub) - queue = _ProcessSonosEventQueue(self.update_volume) - player.renderingControl.subscribe(auto_renew=True, event_queue=queue) - - queue = _ProcessSonosEventQueue(self.update_groups) - player.zoneGroupTopology.subscribe(auto_renew=True, event_queue=queue) - - queue = _ProcessSonosEventQueue(self.update_content) - player.contentDirectory.subscribe(auto_renew=True, event_queue=queue) + subscribe(player.avTransport, self.update_media) + subscribe(player.renderingControl, self.update_volume) + subscribe(player.zoneGroupTopology, self.update_groups) + subscribe(player.contentDirectory, self.update_content) def update(self): """Retrieve latest state.""" @@ -502,6 +505,10 @@ class SonosDevice(MediaPlayerDevice): self._set_basic_information() self._subscribe_to_player_events() else: + for subscription in self._subscriptions: + self.hass.async_add_executor_job(subscription.unsubscribe) + self._subscriptions = [] + self._player_volume = None self._player_muted = None self._status = 'OFF' @@ -706,16 +713,19 @@ class SonosDevice(MediaPlayerDevice): if group: # New group information is pushed coordinator_uid, *slave_uids = group.split(',') - elif self.soco.group: - # Use SoCo cache for existing topology - coordinator_uid = self.soco.group.coordinator.uid - slave_uids = [p.uid for p in self.soco.group.members - if p.uid != coordinator_uid] else: - # Not yet in the cache, this can happen when a speaker boots coordinator_uid = self.unique_id slave_uids = [] + # Try SoCo cache for existing topology + try: + if self.soco.group and self.soco.group.coordinator: + coordinator_uid = self.soco.group.coordinator.uid + slave_uids = [p.uid for p in self.soco.group.members + if p.uid != coordinator_uid] + except requests.exceptions.RequestException: + pass + if self.unique_id == coordinator_uid: sonos_group = [] for uid in (coordinator_uid, *slave_uids): diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index b794fe607e6..529df41de58 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,7 +4,7 @@ from homeassistant.helpers import config_entry_flow DOMAIN = 'sonos' -REQUIREMENTS = ['pysonos==0.0.3'] +REQUIREMENTS = ['pysonos==0.0.5'] async def async_setup(hass, config): diff --git a/requirements_all.txt b/requirements_all.txt index 19466f0236e..0a1f17c23d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ pysma==0.2.2 pysnmp==4.4.5 # homeassistant.components.sonos -pysonos==0.0.3 +pysonos==0.0.5 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ac42d90948..2df1cf072bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -186,7 +186,7 @@ pyotp==2.2.6 pyqwikswitch==0.8 # homeassistant.components.sonos -pysonos==0.0.3 +pysonos==0.0.5 # homeassistant.components.spc pyspcwebgw==0.4.0 From 4751ad69a7c953bf614b349b878a869a6d0189b3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 17 Nov 2018 13:20:17 +0100 Subject: [PATCH 176/238] Upgrade ruamel.yaml to 0.15.78 (#18508) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d31e3523f48..7e225cceaee 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 requests==2.20.0 -ruamel.yaml==0.15.72 +ruamel.yaml==0.15.78 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0a1f17c23d3..ea1573d65be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -12,7 +12,7 @@ pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 requests==2.20.0 -ruamel.yaml==0.15.72 +ruamel.yaml==0.15.78 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/setup.py b/setup.py index 7cd551b573a..9e24362fe8a 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ REQUIRES = [ 'pytz>=2018.04', 'pyyaml>=3.13,<4', 'requests==2.20.0', - 'ruamel.yaml==0.15.72', + 'ruamel.yaml==0.15.78', 'voluptuous==0.11.5', 'voluptuous-serialize==2.0.0', ] From 3f747f1a8cb11348241b28043d2c453559e92eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 17 Nov 2018 18:39:38 +0100 Subject: [PATCH 177/238] Mill lib (#18529) * Mill attr and update the lib * lib * heater_generation --- homeassistant/components/climate/mill.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index bfeaba001b8..3b26b456067 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.4'] +REQUIREMENTS = ['millheater==0.2.7'] _LOGGER = logging.getLogger(__name__) @@ -117,6 +117,7 @@ class MillHeater(ClimateDevice): "open_window": self._heater.open_window, "heating": self._heater.is_heating, "controlled_by_tibber": self._heater.tibber_control, + "heater_generation": 1 if self._heater.is_gen1 else 2, } if self._heater.room: res['room'] = self._heater.room.name diff --git a/requirements_all.txt b/requirements_all.txt index ea1573d65be..097b2c03a10 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -631,7 +631,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.4 +millheater==0.2.7 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 From dfb8f60fe22fe176fe8e8a31cf3c7dfc98071c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 18 Nov 2018 10:33:01 +0200 Subject: [PATCH 178/238] Upgrade pytest and pytest-cov (#18542) * Upgrade pytest to 4.0.0 * Upgrade pytest-cov to 2.6.0 --- requirements_test.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9f34d9ee861..204bc67b086 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,8 +10,8 @@ mypy==0.641 pydocstyle==2.1.1 pylint==2.1.1 pytest-aiohttp==0.3.0 -pytest-cov==2.5.1 +pytest-cov==2.6.0 pytest-sugar==0.9.2 pytest-timeout==1.3.2 -pytest==3.10.0 +pytest==4.0.0 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2df1cf072bb..f904a96b520 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -11,10 +11,10 @@ mypy==0.641 pydocstyle==2.1.1 pylint==2.1.1 pytest-aiohttp==0.3.0 -pytest-cov==2.5.1 +pytest-cov==2.6.0 pytest-sugar==0.9.2 pytest-timeout==1.3.2 -pytest==3.10.0 +pytest==4.0.0 requests_mock==1.5.2 From 8f59be2059adf59821ed0352b09044d285119c15 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 18 Nov 2018 10:37:03 -0700 Subject: [PATCH 179/238] Make MyQ platform async (#18489) * Make MyQ platform async * Bumped requirements * Member comments * Member updates --- homeassistant/components/cover/myq.py | 101 ++++++++++---------------- requirements_all.txt | 2 +- 2 files changed, 41 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index 5ceb4260d0c..bdff232fec9 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -9,18 +9,15 @@ import logging import voluptuous as vol from homeassistant.components.cover import ( - CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN) + PLATFORM_SCHEMA, SUPPORT_CLOSE, SUPPORT_OPEN, CoverDevice) from homeassistant.const import ( CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pymyq==0.0.15'] +from homeassistant.helpers import aiohttp_client, config_validation as cv +REQUIREMENTS = ['pymyq==1.0.0'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'myq' - MYQ_TO_HASS = { 'closed': STATE_CLOSED, 'closing': STATE_CLOSING, @@ -28,95 +25,69 @@ MYQ_TO_HASS = { 'opening': STATE_OPENING } -NOTIFICATION_ID = 'myq_notification' -NOTIFICATION_TITLE = 'MyQ Cover Setup' - -COVER_SCHEMA = vol.Schema({ +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TYPE): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string }) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the MyQ component.""" - from pymyq import MyQAPI as pymyq +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the platform.""" + from pymyq import login + from pymyq.errors import MyQError, UnsupportedBrandError - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - brand = config.get(CONF_TYPE) - myq = pymyq(username, password, brand) + websession = aiohttp_client.async_get_clientsession(hass) + + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + brand = config[CONF_TYPE] try: - if not myq.is_supported_brand(): - raise ValueError("Unsupported type. See documentation") + myq = await login(username, password, brand, websession) + except UnsupportedBrandError: + _LOGGER.error('Unsupported brand: %s', brand) + return + except MyQError as err: + _LOGGER.error('There was an error while logging in: %s', err) + return - if not myq.is_login_valid(): - raise ValueError("Username or Password is incorrect") - - add_entities(MyQDevice(myq, door) for door in myq.get_garage_doors()) - return True - - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - hass.components.persistent_notification.create( - 'Error: {}
' - 'You will need to restart hass after fixing.' - ''.format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False + devices = await myq.get_devices() + async_add_entities([MyQDevice(device) for device in devices], True) class MyQDevice(CoverDevice): """Representation of a MyQ cover.""" - def __init__(self, myq, device): + def __init__(self, device): """Initialize with API object, device id.""" - self.myq = myq - self.device_id = device['deviceid'] - self._name = device['name'] - self._status = None + self._device = device @property def device_class(self): """Define this cover as a garage door.""" return 'garage' - @property - def should_poll(self): - """Poll for state.""" - return True - @property def name(self): """Return the name of the garage door if any.""" - return self._name if self._name else DEFAULT_NAME + return self._device.name @property def is_closed(self): """Return true if cover is closed, else False.""" - if self._status in [None, False]: - return None - return MYQ_TO_HASS.get(self._status) == STATE_CLOSED + return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSED @property def is_closing(self): """Return if the cover is closing or not.""" - return MYQ_TO_HASS.get(self._status) == STATE_CLOSING + return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return MYQ_TO_HASS.get(self._status) == STATE_OPENING - - def close_cover(self, **kwargs): - """Issue close command to cover.""" - self.myq.close_device(self.device_id) - - def open_cover(self, **kwargs): - """Issue open command to cover.""" - self.myq.open_device(self.device_id) + return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING @property def supported_features(self): @@ -126,8 +97,16 @@ class MyQDevice(CoverDevice): @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return self.device_id + return self._device.device_id - def update(self): + async def async_close_cover(self, **kwargs): + """Issue close command to cover.""" + await self._device.close() + + async def async_open_cover(self, **kwargs): + """Issue open command to cover.""" + await self._device.open() + + async def async_update(self): """Update status of cover.""" - self._status = self.myq.get_status(self.device_id) + await self._device.update() diff --git a/requirements_all.txt b/requirements_all.txt index 097b2c03a10..5d70a1ac3f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1044,7 +1044,7 @@ pymonoprice==0.3 pymusiccast==0.1.6 # homeassistant.components.cover.myq -pymyq==0.0.15 +pymyq==1.0.0 # homeassistant.components.mysensors pymysensors==0.18.0 From 796933de6811eff61a617340226c4e1c6b41128a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Sandstr=C3=B6m?= Date: Sun, 18 Nov 2018 19:41:24 +0100 Subject: [PATCH 180/238] Upgrade vsure to 1.5.2 (#18557) Upgrade vsure to 1.5.2 --- homeassistant/components/verisure.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 2c8c34fa67d..2f2fa194846 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['vsure==1.5.0', 'jsonpath==0.75'] +REQUIREMENTS = ['vsure==1.5.2', 'jsonpath==0.75'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 5d70a1ac3f5..76cb14fdf1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1569,7 +1569,7 @@ volkszaehler==0.1.2 volvooncall==0.4.0 # homeassistant.components.verisure -vsure==1.5.0 +vsure==1.5.2 # homeassistant.components.sensor.vasttrafik vtjp==0.1.14 From b066877453ebe8d840f699721f0f644254e68acc Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 18 Nov 2018 19:51:17 +0100 Subject: [PATCH 181/238] Allow unloading of LIFX config entry (#18535) --- homeassistant/components/lifx/__init__.py | 14 +++++- homeassistant/components/light/lifx.py | 56 +++++++++++++++-------- requirements_all.txt | 2 +- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 1ca6c00b23a..52df3d47ca1 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN DOMAIN = 'lifx' -REQUIREMENTS = ['aiolifx==0.6.5'] +REQUIREMENTS = ['aiolifx==0.6.6'] CONF_SERVER = 'server' CONF_BROADCAST = 'broadcast' @@ -25,6 +25,8 @@ CONFIG_SCHEMA = vol.Schema({ } }, extra=vol.ALLOW_EXTRA) +DATA_LIFX_MANAGER = 'lifx_manager' + async def async_setup(hass, config): """Set up the LIFX component.""" @@ -43,6 +45,16 @@ async def async_setup_entry(hass, entry): """Set up LIFX from a config entry.""" hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, LIGHT_DOMAIN)) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.data.pop(DATA_LIFX_MANAGER).cleanup() + + await hass.config_entries.async_forward_entry_unload(entry, LIGHT_DOMAIN) + return True diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index f346f88c42b..8951b2876a2 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, Light, preprocess_turn_on_alternatives) from homeassistant.components.lifx import ( - DOMAIN as LIFX_DOMAIN, CONF_SERVER, CONF_BROADCAST) + DOMAIN as LIFX_DOMAIN, DATA_LIFX_MANAGER, CONF_SERVER, CONF_BROADCAST) from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -155,27 +155,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): interfaces = [{}] lifx_manager = LIFXManager(hass, async_add_entities) + hass.data[DATA_LIFX_MANAGER] = lifx_manager for interface in interfaces: - kwargs = {'discovery_interval': DISCOVERY_INTERVAL} - broadcast_ip = interface.get(CONF_BROADCAST) - if broadcast_ip: - kwargs['broadcast_ip'] = broadcast_ip - lifx_discovery = aiolifx().LifxDiscovery( - hass.loop, lifx_manager, **kwargs) - - kwargs = {} - listen_ip = interface.get(CONF_SERVER) - if listen_ip: - kwargs['listen_ip'] = listen_ip - lifx_discovery.start(**kwargs) - - @callback - def cleanup(event): - """Clean up resources.""" - lifx_discovery.cleanup() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + lifx_manager.start_discovery(interface) return True @@ -226,10 +209,43 @@ class LIFXManager: self.hass = hass self.async_add_entities = async_add_entities self.effects_conductor = aiolifx_effects().Conductor(loop=hass.loop) + self.discoveries = [] + self.cleanup_unsub = self.hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, + self.cleanup) self.register_set_state() self.register_effects() + def start_discovery(self, interface): + """Start discovery on a network interface.""" + kwargs = {'discovery_interval': DISCOVERY_INTERVAL} + broadcast_ip = interface.get(CONF_BROADCAST) + if broadcast_ip: + kwargs['broadcast_ip'] = broadcast_ip + lifx_discovery = aiolifx().LifxDiscovery( + self.hass.loop, self, **kwargs) + + kwargs = {} + listen_ip = interface.get(CONF_SERVER) + if listen_ip: + kwargs['listen_ip'] = listen_ip + lifx_discovery.start(**kwargs) + + self.discoveries.append(lifx_discovery) + + @callback + def cleanup(self, event=None): + """Release resources.""" + self.cleanup_unsub() + + for discovery in self.discoveries: + discovery.cleanup() + + for service in [SERVICE_LIFX_SET_STATE, SERVICE_EFFECT_STOP, + SERVICE_EFFECT_PULSE, SERVICE_EFFECT_COLORLOOP]: + self.hass.services.async_remove(DOMAIN, service) + def register_set_state(self): """Register the LIFX set_state service call.""" async def service_handler(service): diff --git a/requirements_all.txt b/requirements_all.txt index 76cb14fdf1a..67b0bebdd97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -111,7 +111,7 @@ aiohue==1.5.0 aioimaplib==0.7.13 # homeassistant.components.lifx -aiolifx==0.6.5 +aiolifx==0.6.6 # homeassistant.components.light.lifx aiolifx_effects==0.2.1 From afe21b440840bf14bab20c0134d0f72d3d8b6bbe Mon Sep 17 00:00:00 2001 From: sdelliot Date: Sun, 18 Nov 2018 18:35:36 -0700 Subject: [PATCH 182/238] Re-adding the season attribute (#18523) --- homeassistant/components/sensor/pollen.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index c5acad837bc..8f187b82fd2 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -245,6 +245,7 @@ class ForecastSensor(BaseSensor): if self._kind == TYPE_ALLERGY_FORECAST: outlook = self.pollen.data[TYPE_ALLERGY_OUTLOOK] self._attrs[ATTR_OUTLOOK] = outlook['Outlook'] + self._attrs[ATTR_SEASON] = outlook['Season'] self._state = average From ab8c127a4a466f2c137239c0eb06c8143adea106 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Sun, 18 Nov 2018 22:05:58 -0800 Subject: [PATCH 183/238] Enable native support + ADB authentication for Fire TV (#17767) * Enable native support + ADB authentication for Fire TV * Remove unnecessary underscore assignments * Bump firetv to 1.0.5.3 * Change requirements to 'firetv>=1.0.6' * Change requirement from 'firetv>=1.0.6' to 'firetv==1.0.6' * Address pylint errors * Ran 'python script/gen_requirements_all.py' * Address some minor reviewer comments * Run 'python script/gen_requirements_all.py' * Just use the 'requirements_all.txt' and 'requirements_test_all.txt' from the 'dev' branch... * Edit the 'requirements_all.txt' file manually * Pass flake8 tests * Pass pylint tests, add extended description for 'select_source' * More precise exception catching * More Pythonic returns * Import exceptions inside '__init__' * Remove 'time.sleep' command * Sort the imports * Use 'config[key]' instead of 'config.get(key)' * Remove accessing of hidden attributes; bump firetv version to 1.0.7 * Bump firetv to 1.0.7 in 'requirements_all.txt' * Don't access 'self.firetv._adb', use 'self.available' instead * Remove '_host' and '_adbkey' attributes * Create the 'FireTV' object in 'setup_platform' and check the connection before instantiating the entity * Fixed config validation for 'adbkey' * add_devices -> add_entities * Remove 'pylint: disable=no-name-in-module' * Don't assume the device is available after attempting to connect * Update the state after reconnecting * Modifications to 'adb_decorator' * Modifications to 'setup_platform' * Don't update the state if the ADB reconnect attempt was unsuccessful * 'return None' -> 'return' * Use 'threading.Lock()' instead of a boolean for 'adb_lock' * Use a non-blocking 'threading.Lock' --- .../components/media_player/firetv.py | 353 ++++++++++-------- requirements_all.txt | 3 + 2 files changed, 210 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 3914d2381b2..0c1984b3bce 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -4,166 +4,147 @@ Support for functionality to interact with FireTV devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.firetv/ """ +import functools import logging - -import requests +import threading import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, MediaPlayerDevice) + MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, ) from homeassistant.const import ( - CONF_DEVICE, CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, - STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, - STATE_UNKNOWN) + CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, + STATE_PLAYING, STATE_STANDBY) import homeassistant.helpers.config_validation as cv +REQUIREMENTS = ['firetv==1.0.7'] + _LOGGER = logging.getLogger(__name__) SUPPORT_FIRETV = SUPPORT_PAUSE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET | \ - SUPPORT_PLAY + SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ + SUPPORT_VOLUME_SET | SUPPORT_PLAY + +CONF_ADBKEY = 'adbkey' +CONF_GET_SOURCE = 'get_source' +CONF_GET_SOURCES = 'get_sources' -DEFAULT_SSL = False -DEFAULT_DEVICE = 'default' -DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Amazon Fire TV' -DEFAULT_PORT = 5556 -DEVICE_ACTION_URL = '{0}://{1}:{2}/devices/action/{3}/{4}' -DEVICE_LIST_URL = '{0}://{1}:{2}/devices/list' -DEVICE_STATE_URL = '{0}://{1}:{2}/devices/state/{3}' -DEVICE_APPS_URL = '{0}://{1}:{2}/devices/{3}/apps/{4}' +DEFAULT_PORT = 5555 +DEFAULT_GET_SOURCE = True +DEFAULT_GET_SOURCES = True + + +def has_adb_files(value): + """Check that ADB key files exist.""" + priv_key = value + pub_key = '{}.pub'.format(value) + cv.isfile(pub_key) + return cv.isfile(priv_key) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_ADBKEY): has_adb_files, + vol.Optional(CONF_GET_SOURCE, default=DEFAULT_GET_SOURCE): cv.boolean, + vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean }) +PACKAGE_LAUNCHER = "com.amazon.tv.launcher" +PACKAGE_SETTINGS = "com.amazon.tv.settings" + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the FireTV platform.""" - name = config.get(CONF_NAME) - ssl = config.get(CONF_SSL) - proto = 'https' if ssl else 'http' - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - device_id = config.get(CONF_DEVICE) + from firetv import FireTV - try: - response = requests.get( - DEVICE_LIST_URL.format(proto, host, port)).json() - if device_id in response[CONF_DEVICES].keys(): - add_entities([FireTVDevice(proto, host, port, device_id, name)]) - _LOGGER.info("Device %s accessible and ready for control", - device_id) - else: - _LOGGER.warning("Device %s is not registered with firetv-server", - device_id) - except requests.exceptions.RequestException: - _LOGGER.error("Could not connect to firetv-server at %s", host) + host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT]) + + if CONF_ADBKEY in config: + ftv = FireTV(host, config[CONF_ADBKEY]) + adb_log = " using adbkey='{0}'".format(config[CONF_ADBKEY]) + else: + ftv = FireTV(host) + adb_log = "" + + if not ftv.available: + _LOGGER.warning("Could not connect to Fire TV at %s%s", host, adb_log) + return + + name = config[CONF_NAME] + get_source = config[CONF_GET_SOURCE] + get_sources = config[CONF_GET_SOURCES] + + device = FireTVDevice(ftv, name, get_source, get_sources) + add_entities([device]) + _LOGGER.info("Setup Fire TV at %s%s", host, adb_log) -class FireTV: - """The firetv-server client. +def adb_decorator(override_available=False): + """Send an ADB command if the device is available and not locked.""" + def adb_wrapper(func): + """Wait if previous ADB commands haven't finished.""" + @functools.wraps(func) + def _adb_wrapper(self, *args, **kwargs): + # If the device is unavailable, don't do anything + if not self.available and not override_available: + return None - Should a native Python 3 ADB module become available, python-firetv can - support Python 3, it can be added as a dependency, and this class can be - dispensed of. + # If an ADB command is already running, skip this command + if not self.adb_lock.acquire(blocking=False): + _LOGGER.info('Skipping an ADB command because a previous ' + 'command is still running') + return None - For now, it acts as a client to the firetv-server HTTP server (which must - be running via Python 2). - """ + # Additional ADB commands will be prevented while trying this one + try: + returns = func(self, *args, **kwargs) + except self.exceptions: + _LOGGER.error('Failed to execute an ADB command; will attempt ' + 'to re-establish the ADB connection in the next ' + 'update') + returns = None + self._available = False # pylint: disable=protected-access + finally: + self.adb_lock.release() - def __init__(self, proto, host, port, device_id): - """Initialize the FireTV server.""" - self.proto = proto - self.host = host - self.port = port - self.device_id = device_id + return returns - @property - def state(self): - """Get the device state. An exception means UNKNOWN state.""" - try: - response = requests.get( - DEVICE_STATE_URL.format( - self.proto, self.host, self.port, self.device_id - ), timeout=10).json() - return response.get('state', STATE_UNKNOWN) - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve device state for %s", self.device_id) - return STATE_UNKNOWN + return _adb_wrapper - @property - def current_app(self): - """Return the current app.""" - try: - response = requests.get( - DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, 'current' - ), timeout=10).json() - _current_app = response.get('current_app') - if _current_app: - return _current_app.get('package') - - return None - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve current app for %s", self.device_id) - return None - - @property - def running_apps(self): - """Return a list of running apps.""" - try: - response = requests.get( - DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, 'running' - ), timeout=10).json() - return response.get('running_apps') - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve running apps for %s", self.device_id) - return None - - def action(self, action_id): - """Perform an action on the device.""" - try: - requests.get(DEVICE_ACTION_URL.format( - self.proto, self.host, self.port, self.device_id, action_id - ), timeout=10) - except requests.exceptions.RequestException: - _LOGGER.error( - "Action request for %s was not accepted for device %s", - action_id, self.device_id) - - def start_app(self, app_name): - """Start an app.""" - try: - requests.get(DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, - app_name + '/start'), timeout=10) - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not start %s on %s", app_name, self.device_id) + return adb_wrapper class FireTVDevice(MediaPlayerDevice): """Representation of an Amazon Fire TV device on the network.""" - def __init__(self, proto, host, port, device, name): + def __init__(self, ftv, name, get_source, get_sources): """Initialize the FireTV device.""" - self._firetv = FireTV(proto, host, port, device) + from adb.adb_protocol import ( + InvalidCommandError, InvalidResponseError, InvalidChecksumError) + + self.firetv = ftv + self._name = name - self._state = STATE_UNKNOWN - self._running_apps = None + self._get_source = get_source + self._get_sources = get_sources + + # whether or not the ADB connection is currently in use + self.adb_lock = threading.Lock() + + # ADB exceptions to catch + self.exceptions = (TypeError, ValueError, AttributeError, + InvalidCommandError, InvalidResponseError, + InvalidChecksumError) + + self._state = None + self._available = self.firetv.available self._current_app = None + self._running_apps = None @property def name(self): @@ -185,6 +166,11 @@ class FireTVDevice(MediaPlayerDevice): """Return the state of the player.""" return self._state + @property + def available(self): + """Return whether or not the ADB connection is valid.""" + return self._available + @property def source(self): """Return the current app.""" @@ -195,60 +181,135 @@ class FireTVDevice(MediaPlayerDevice): """Return a list of running apps.""" return self._running_apps + @adb_decorator(override_available=True) def update(self): """Get the latest date and update device state.""" - self._state = { - 'idle': STATE_IDLE, - 'off': STATE_OFF, - 'play': STATE_PLAYING, - 'pause': STATE_PAUSED, - 'standby': STATE_STANDBY, - 'disconnected': STATE_UNKNOWN, - }.get(self._firetv.state, STATE_UNKNOWN) - - if self._state not in [STATE_OFF, STATE_UNKNOWN]: - self._running_apps = self._firetv.running_apps - self._current_app = self._firetv.current_app - else: + # Check if device is disconnected. + if not self._available: self._running_apps = None self._current_app = None + # Try to connect + self.firetv.connect() + self._available = self.firetv.available + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Check if device is off. + if not self.firetv.screen_on: + self._state = STATE_OFF + self._running_apps = None + self._current_app = None + + # Check if screen saver is on. + elif not self.firetv.awake: + self._state = STATE_IDLE + self._running_apps = None + self._current_app = None + + else: + # Get the running apps. + if self._get_sources: + self._running_apps = self.firetv.running_apps + + # Get the current app. + if self._get_source: + current_app = self.firetv.current_app + if isinstance(current_app, dict)\ + and 'package' in current_app: + self._current_app = current_app['package'] + else: + self._current_app = current_app + + # Show the current app as the only running app. + if not self._get_sources: + if self._current_app: + self._running_apps = [self._current_app] + else: + self._running_apps = None + + # Check if the launcher is active. + if self._current_app in [PACKAGE_LAUNCHER, + PACKAGE_SETTINGS]: + self._state = STATE_STANDBY + + # Check for a wake lock (device is playing). + elif self.firetv.wake_lock: + self._state = STATE_PLAYING + + # Otherwise, device is paused. + else: + self._state = STATE_PAUSED + + # Don't get the current app. + elif self.firetv.wake_lock: + # Check for a wake lock (device is playing). + self._state = STATE_PLAYING + else: + # Assume the devices is on standby. + self._state = STATE_STANDBY + + @adb_decorator() def turn_on(self): """Turn on the device.""" - self._firetv.action('turn_on') + self.firetv.turn_on() + @adb_decorator() def turn_off(self): """Turn off the device.""" - self._firetv.action('turn_off') + self.firetv.turn_off() + @adb_decorator() def media_play(self): """Send play command.""" - self._firetv.action('media_play') + self.firetv.media_play() + @adb_decorator() def media_pause(self): """Send pause command.""" - self._firetv.action('media_pause') + self.firetv.media_pause() + @adb_decorator() def media_play_pause(self): """Send play/pause command.""" - self._firetv.action('media_play_pause') + self.firetv.media_play_pause() + @adb_decorator() + def media_stop(self): + """Send stop (back) command.""" + self.firetv.back() + + @adb_decorator() def volume_up(self): """Send volume up command.""" - self._firetv.action('volume_up') + self.firetv.volume_up() + @adb_decorator() def volume_down(self): """Send volume down command.""" - self._firetv.action('volume_down') + self.firetv.volume_down() + @adb_decorator() def media_previous_track(self): """Send previous track command (results in rewind).""" - self._firetv.action('media_previous') + self.firetv.media_previous() + @adb_decorator() def media_next_track(self): """Send next track command (results in fast-forward).""" - self._firetv.action('media_next') + self.firetv.media_next() + @adb_decorator() def select_source(self, source): - """Select input source.""" - self._firetv.start_app(source) + """Select input source. + + If the source starts with a '!', then it will close the app instead of + opening it. + """ + if isinstance(source, str): + if not source.startswith('!'): + self.firetv.launch_app(source) + else: + self.firetv.stop_app(source[1:].lstrip()) diff --git a/requirements_all.txt b/requirements_all.txt index 67b0bebdd97..098f8d25c19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -379,6 +379,9 @@ fiblary3==0.1.7 # homeassistant.components.sensor.fints fints==1.0.1 +# homeassistant.components.media_player.firetv +firetv==1.0.7 + # homeassistant.components.sensor.fitbit fitbit==0.3.0 From 7e702d3caac762dbafcb705b21db8ee88ec5aaad Mon Sep 17 00:00:00 2001 From: emontnemery Date: Mon, 19 Nov 2018 09:59:07 +0100 Subject: [PATCH 184/238] Fix small issue related to topic prefix (#18512) Fix expansion of topic prefix when discovery message contains non string-type items. --- homeassistant/components/mqtt/discovery.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 91f62cd0848..f680cd9c136 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -202,10 +202,11 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, if TOPIC_BASE in payload: base = payload[TOPIC_BASE] for key, value in payload.items(): - if value[0] == TOPIC_BASE and key.endswith('_topic'): - payload[key] = "{}{}".format(base, value[1:]) - if value[-1] == TOPIC_BASE and key.endswith('_topic'): - payload[key] = "{}{}".format(value[:-1], base) + if isinstance(value, str): + if value[0] == TOPIC_BASE and key.endswith('_topic'): + payload[key] = "{}{}".format(base, value[1:]) + if value[-1] == TOPIC_BASE and key.endswith('_topic'): + payload[key] = "{}{}".format(value[:-1], base) # If present, the node_id will be included in the discovered object id discovery_id = '_'.join((node_id, object_id)) if node_id else object_id From f241becf7f1976ab148e396e38619542c8a9121d Mon Sep 17 00:00:00 2001 From: emontnemery Date: Mon, 19 Nov 2018 10:13:50 +0100 Subject: [PATCH 185/238] Avoid race in entity_platform.async_add_entities() (#18445) This avoids a race between multiple concurrent calls to entity_platform.async_add_entities() which may cause entities to be created with non-unique entity_id --- homeassistant/helpers/entity_platform.py | 18 +++++++----------- homeassistant/helpers/entity_registry.py | 6 ++++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 5fd580a33f0..ec7b5579342 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -206,7 +206,6 @@ class EntityPlatform: return hass = self.hass - component_entities = set(hass.states.async_entity_ids(self.domain)) device_registry = await \ hass.helpers.device_registry.async_get_registry() @@ -214,8 +213,7 @@ class EntityPlatform: hass.helpers.entity_registry.async_get_registry() tasks = [ self._async_add_entity(entity, update_before_add, - component_entities, entity_registry, - device_registry) + entity_registry, device_registry) for entity in new_entities] # No entities for processing @@ -235,8 +233,7 @@ class EntityPlatform: ) async def _async_add_entity(self, entity, update_before_add, - component_entities, entity_registry, - device_registry): + entity_registry, device_registry): """Add an entity to the platform.""" if entity is None: raise ValueError('Entity cannot be None') @@ -329,25 +326,24 @@ class EntityPlatform: if self.entity_namespace is not None: suggested_object_id = '{} {}'.format(self.entity_namespace, suggested_object_id) - entity.entity_id = entity_registry.async_generate_entity_id( - self.domain, suggested_object_id) + self.domain, suggested_object_id, self.entities.keys()) # Make sure it is valid in case an entity set the value themselves if not valid_entity_id(entity.entity_id): raise HomeAssistantError( 'Invalid entity id: {}'.format(entity.entity_id)) - elif entity.entity_id in component_entities: + elif (entity.entity_id in self.entities or + entity.entity_id in self.hass.states.async_entity_ids( + self.domain)): msg = 'Entity id already exists: {}'.format(entity.entity_id) if entity.unique_id is not None: msg += '. Platform {} does not generate unique IDs'.format( self.platform_name) - raise HomeAssistantError( - msg) + raise HomeAssistantError(msg) entity_id = entity.entity_id self.entities[entity_id] = entity - component_entities.add(entity_id) entity.async_on_remove(lambda: self.entities.pop(entity_id)) if hasattr(entity, 'async_added_to_hass'): diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 5adf748dc58..c40d14652ad 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -95,7 +95,8 @@ class EntityRegistry: return None @callback - def async_generate_entity_id(self, domain, suggested_object_id): + def async_generate_entity_id(self, domain, suggested_object_id, + known_object_ids=None): """Generate an entity ID that does not conflict. Conflicts checked against registered and currently existing entities. @@ -103,7 +104,8 @@ class EntityRegistry: return ensure_unique_string( '{}.{}'.format(domain, slugify(suggested_object_id)), chain(self.entities.keys(), - self.hass.states.async_entity_ids(domain)) + self.hass.states.async_entity_ids(domain), + known_object_ids if known_object_ids else []) ) @callback From 089a2f4e71e8e6c6c6720a725a7bc3a4c3f7b518 Mon Sep 17 00:00:00 2001 From: Aleksandr Smirnov Date: Mon, 19 Nov 2018 10:36:00 +0100 Subject: [PATCH 186/238] Logbook speedup (#18376) * filter logbook results by entity_id prior to instantiating them * include by default, pass pep8 * pass pylint * use entityfilter, update tests --- homeassistant/components/logbook.py | 123 +++++++++++------- .../components/recorder/migration.py | 2 + homeassistant/components/recorder/models.py | 7 +- tests/components/test_logbook.py | 49 ++++--- 4 files changed, 112 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 5bd7ed0d2f5..ada8bf78ab0 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -317,43 +317,90 @@ def humanify(hass, events): } +def _get_related_entity_ids(session, entity_filter): + from homeassistant.components.recorder.models import States + from homeassistant.components.recorder.util import \ + RETRIES, QUERY_RETRY_WAIT + from sqlalchemy.exc import SQLAlchemyError + import time + + timer_start = time.perf_counter() + + query = session.query(States).with_entities(States.entity_id).distinct() + + for tryno in range(0, RETRIES): + try: + result = [ + row.entity_id for row in query + if entity_filter(row.entity_id)] + + if _LOGGER.isEnabledFor(logging.DEBUG): + elapsed = time.perf_counter() - timer_start + _LOGGER.debug( + 'fetching %d distinct domain/entity_id pairs took %fs', + len(result), + elapsed) + + return result + except SQLAlchemyError as err: + _LOGGER.error("Error executing query: %s", err) + + if tryno == RETRIES - 1: + raise + else: + time.sleep(QUERY_RETRY_WAIT) + + +def _generate_filter_from_config(config): + from homeassistant.helpers.entityfilter import generate_filter + + excluded_entities = [] + excluded_domains = [] + included_entities = [] + included_domains = [] + + exclude = config.get(CONF_EXCLUDE) + if exclude: + excluded_entities = exclude.get(CONF_ENTITIES, []) + excluded_domains = exclude.get(CONF_DOMAINS, []) + include = config.get(CONF_INCLUDE) + if include: + included_entities = include.get(CONF_ENTITIES, []) + included_domains = include.get(CONF_DOMAINS, []) + + return generate_filter(included_domains, included_entities, + excluded_domains, excluded_entities) + + def _get_events(hass, config, start_day, end_day, entity_id=None): """Get events for a period of time.""" from homeassistant.components.recorder.models import Events, States from homeassistant.components.recorder.util import ( execute, session_scope) + entities_filter = _generate_filter_from_config(config) + with session_scope(hass=hass) as session: + if entity_id is not None: + entity_ids = [entity_id.lower()] + else: + entity_ids = _get_related_entity_ids(session, entities_filter) + query = session.query(Events).order_by(Events.time_fired) \ - .outerjoin(States, (Events.event_id == States.event_id)) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ .filter((Events.time_fired > start_day) & (Events.time_fired < end_day)) \ .filter((States.last_updated == States.last_changed) - | (States.state_id.is_(None))) - - if entity_id is not None: - query = query.filter(States.entity_id == entity_id.lower()) + | (States.state_id.is_(None))) \ + .filter(States.entity_id.in_(entity_ids)) events = execute(query) - return humanify(hass, _exclude_events(events, config)) + + return humanify(hass, _exclude_events(events, entities_filter)) -def _exclude_events(events, config): - """Get list of filtered events.""" - excluded_entities = [] - excluded_domains = [] - included_entities = [] - included_domains = [] - exclude = config.get(CONF_EXCLUDE) - if exclude: - excluded_entities = exclude[CONF_ENTITIES] - excluded_domains = exclude[CONF_DOMAINS] - include = config.get(CONF_INCLUDE) - if include: - included_entities = include[CONF_ENTITIES] - included_domains = include[CONF_DOMAINS] - +def _exclude_events(events, entities_filter): filtered_events = [] for event in events: domain, entity_id = None, None @@ -398,34 +445,12 @@ def _exclude_events(events, config): domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) - if domain or entity_id: - # filter if only excluded is configured for this domain - if excluded_domains and domain in excluded_domains and \ - not included_domains: - if (included_entities and entity_id not in included_entities) \ - or not included_entities: - continue - # filter if only included is configured for this domain - elif not excluded_domains and included_domains and \ - domain not in included_domains: - if (included_entities and entity_id not in included_entities) \ - or not included_entities: - continue - # filter if included and excluded is configured for this domain - elif excluded_domains and included_domains and \ - (domain not in included_domains or - domain in excluded_domains): - if (included_entities and entity_id not in included_entities) \ - or not included_entities or domain in excluded_domains: - continue - # filter if only included is configured for this entity - elif not excluded_domains and not included_domains and \ - included_entities and entity_id not in included_entities: - continue - # check if logbook entry is excluded for this entity - if entity_id in excluded_entities: - continue - filtered_events.append(event) + if not entity_id and domain: + entity_id = "%s." % (domain, ) + + if not entity_id or entities_filter(entity_id): + filtered_events.append(event) + return filtered_events diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 45c8f939faf..a6a6ed46174 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -218,6 +218,8 @@ def _apply_update(engine, new_version, old_version): ]) _create_index(engine, "states", "ix_states_context_id") _create_index(engine, "states", "ix_states_context_user_id") + elif new_version == 7: + _create_index(engine, "states", "ix_states_entity_id") else: raise ValueError("No schema migration defined for version {}" .format(new_version)) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 700dd57eacf..7a655c29434 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -17,7 +17,7 @@ from homeassistant.helpers.json import JSONEncoder # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 6 +SCHEMA_VERSION = 7 _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,7 @@ class States(Base): # type: ignore __tablename__ = 'states' state_id = Column(Integer, primary_key=True) domain = Column(String(64)) - entity_id = Column(String(255)) + entity_id = Column(String(255), index=True) state = Column(String(255)) attributes = Column(Text) event_id = Column(Integer, ForeignKey('events.event_id'), index=True) @@ -86,7 +86,8 @@ class States(Base): # type: ignore # Used for fetching the state of entities at a specific time # (get_states in history.py) Index( - 'ix_states_entity_id_last_updated', 'entity_id', 'last_updated'),) + 'ix_states_entity_id_last_updated', 'entity_id', 'last_updated'), + ) @staticmethod def from_event(event): diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 89528c1772b..5229d34b74c 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -136,8 +136,10 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointB, entity_id2, 20) eventA.data['old_state'] = None - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), {}) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -158,8 +160,10 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointB, entity_id2, 20) eventA.data['new_state'] = None - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), {}) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -180,8 +184,10 @@ class TestComponentLogbook(unittest.TestCase): {ATTR_HIDDEN: 'true'}) eventB = self.create_state_changed_event(pointB, entity_id2, 20) - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), {}) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -207,7 +213,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_ENTITIES: [entity_id, ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -233,7 +239,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_DOMAINS: ['switch', ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -270,7 +276,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_ENTITIES: [entity_id, ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -296,7 +302,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_ENTITIES: [entity_id2, ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -322,7 +328,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_DOMAINS: ['sensor', ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -356,15 +362,20 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_ENTITIES: ['sensor.bli', ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_START), eventA1, eventA2, eventA3, - eventB1, eventB2), config[logbook.DOMAIN]) + eventB1, eventB2), + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) - assert 3 == len(entries) + assert 5 == len(entries) self.assert_entry(entries[0], name='Home Assistant', message='started', domain=ha.DOMAIN) - self.assert_entry(entries[1], pointA, 'blu', domain='sensor', + self.assert_entry(entries[1], pointA, 'bla', domain='switch', + entity_id=entity_id) + self.assert_entry(entries[2], pointA, 'blu', domain='sensor', entity_id=entity_id2) - self.assert_entry(entries[2], pointB, 'blu', domain='sensor', + self.assert_entry(entries[3], pointB, 'bla', domain='switch', + entity_id=entity_id) + self.assert_entry(entries[4], pointB, 'blu', domain='sensor', entity_id=entity_id2) def test_exclude_auto_groups(self): @@ -377,7 +388,9 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointA, entity_id2, 20, {'auto': True}) - events = logbook._exclude_events((eventA, eventB), {}) + events = logbook._exclude_events( + (eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 1 == len(entries) @@ -395,7 +408,9 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event( pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB) - events = logbook._exclude_events((eventA, eventB), {}) + events = logbook._exclude_events( + (eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 1 == len(entries) From 83b4e5697845f4505e328d3e3294a813d4410d7c Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 19 Nov 2018 04:14:00 -0600 Subject: [PATCH 187/238] Log delay and wait_template steps in scripts (#18448) * Log delay and wait_template steps in scripts Help improve script debugging by logging delay and wait_template steps in scripts. * Update tests * Fix lint issue --- homeassistant/helpers/script.py | 7 +++++ tests/helpers/test_script.py | 53 +++++++++++++++++++++------------ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 5e660ba7b7f..80d66f4fac8 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -120,6 +120,10 @@ class Script(): self.name, ex) break + self.last_action = action.get( + CONF_ALIAS, 'delay {}'.format(delay)) + self._log("Executing step %s" % self.last_action) + unsub = async_track_point_in_utc_time( self.hass, async_script_delay, date_util.utcnow() + delay @@ -136,6 +140,9 @@ class Script(): wait_template = action[CONF_WAIT_TEMPLATE] wait_template.hass = self.hass + self.last_action = action.get(CONF_ALIAS, 'wait template') + self._log("Executing step %s" % self.last_action) + # check if condition already okay if condition.async_template( self.hass, wait_template, variables): diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index d217b99b3a8..e5e62d2aed3 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -167,6 +167,7 @@ class TestScriptHelper(unittest.TestCase): event = 'test_event' events = [] context = Context() + delay_alias = 'delay step' @callback def record_event(event): @@ -177,7 +178,7 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'delay': {'seconds': 5}}, + {'delay': {'seconds': 5}, 'alias': delay_alias}, {'event': event}])) script_obj.run(context=context) @@ -185,7 +186,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == delay_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -201,6 +202,7 @@ class TestScriptHelper(unittest.TestCase): """Test the delay as a template.""" event = 'test_event' events = [] + delay_alias = 'delay step' @callback def record_event(event): @@ -211,7 +213,7 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'delay': '00:00:{{ 5 }}'}, + {'delay': '00:00:{{ 5 }}', 'alias': delay_alias}, {'event': event}])) script_obj.run() @@ -219,7 +221,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == delay_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -259,6 +261,7 @@ class TestScriptHelper(unittest.TestCase): """Test the delay with a working complex template.""" event = 'test_event' events = [] + delay_alias = 'delay step' @callback def record_event(event): @@ -270,8 +273,8 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, {'delay': { - 'seconds': '{{ 5 }}' - }}, + 'seconds': '{{ 5 }}'}, + 'alias': delay_alias}, {'event': event}])) script_obj.run() @@ -279,7 +282,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == delay_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -358,6 +361,7 @@ class TestScriptHelper(unittest.TestCase): event = 'test_event' events = [] context = Context() + wait_alias = 'wait step' @callback def record_event(event): @@ -370,7 +374,8 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'wait_template': "{{states.switch.test.state == 'off'}}"}, + {'wait_template': "{{states.switch.test.state == 'off'}}", + 'alias': wait_alias}, {'event': event}])) script_obj.run(context=context) @@ -378,7 +383,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 self.hass.states.set('switch.test', 'off') @@ -393,6 +398,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template cancel action.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -405,7 +411,8 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'wait_template': "{{states.switch.test.state == 'off'}}"}, + {'wait_template': "{{states.switch.test.state == 'off'}}", + 'alias': wait_alias}, {'event': event}])) script_obj.run() @@ -413,7 +420,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 script_obj.stop() @@ -457,6 +464,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template, halt on timeout.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -472,7 +480,8 @@ class TestScriptHelper(unittest.TestCase): { 'wait_template': "{{states.switch.test.state == 'off'}}", 'continue_on_timeout': False, - 'timeout': 5 + 'timeout': 5, + 'alias': wait_alias }, {'event': event}])) @@ -481,7 +490,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -495,6 +504,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template with continuing the script.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -510,7 +520,8 @@ class TestScriptHelper(unittest.TestCase): { 'wait_template': "{{states.switch.test.state == 'off'}}", 'timeout': 5, - 'continue_on_timeout': True + 'continue_on_timeout': True, + 'alias': wait_alias }, {'event': event}])) @@ -519,7 +530,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -533,6 +544,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template with default contiune.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -547,7 +559,8 @@ class TestScriptHelper(unittest.TestCase): {'event': event}, { 'wait_template': "{{states.switch.test.state == 'off'}}", - 'timeout': 5 + 'timeout': 5, + 'alias': wait_alias }, {'event': event}])) @@ -556,7 +569,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -570,6 +583,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template with variables.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -582,7 +596,8 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'wait_template': "{{is_state(data, 'off')}}"}, + {'wait_template': "{{is_state(data, 'off')}}", + 'alias': wait_alias}, {'event': event}])) script_obj.run({ @@ -592,7 +607,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 self.hass.states.set('switch.test', 'off') From cdcc818bf9360dc6b7fa841949059deea8ccab6c Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Mon, 19 Nov 2018 03:47:00 -0700 Subject: [PATCH 188/238] Remove turn_on and turn_off feature for clients (#18234) * Enhancements for DirecTV media player Following enhancements have been made: 1. Added debug logging 2. Added ability to change channel using select_source service of the remote platform. 3. State will now show paused if a recorded program is paused, for live TV playing will always be returned. 4. Added the following attributes: a. media_position: current position of the media (in seconds) b. media_position_updated_at: timestamp when media_position was updated. c. source: current source (channel). d. media_isbeingrecorded: if current media is being recorded or not. e. media_rating: TV/Movie rating of the media f. media_recorded: if current media is recorded or live TV g. media_starttime: Timestamp media was aired Reordered properties to follow same order as how they are in __init__.py of remote platform. * Fixed error and cleaned up few items Fixed an issue when determining if a program is recorded or not. Cleaned up some coding. * Added available property Added available property * Disable feature TURN_ON and TURN_OFF for DVR clients Disable the feature turn_on and turn_off for DVR clients. * self._is_client and raise NotImplementedError Updated setting self._is_client Raise NotImplementedError if turn_on or turn_off is called for clients. --- .../components/media_player/directv.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 7ae80172fac..51f5cbc5bb0 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -36,6 +36,10 @@ SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY +SUPPORT_DTV_CLIENT = SUPPORT_PAUSE | \ + SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ + SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY + DATA_DIRECTV = 'data_directv' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -126,10 +130,15 @@ class DirecTvDevice(MediaPlayerDevice): self._paused = None self._last_position = None self._is_recorded = None + self._is_client = device != '0' self._assumed_state = None self._available = False - _LOGGER.debug("Created DirecTV device for %s", self._name) + if self._is_client: + _LOGGER.debug("Created DirecTV client %s for device %s", + self._name, device) + else: + _LOGGER.debug("Created DirecTV device for %s", self._name) def update(self): """Retrieve latest state.""" @@ -290,7 +299,7 @@ class DirecTvDevice(MediaPlayerDevice): @property def supported_features(self): """Flag media player features that are supported.""" - return SUPPORT_DTV + return SUPPORT_DTV_CLIENT if self._is_client else SUPPORT_DTV @property def media_currently_recording(self): @@ -327,11 +336,17 @@ class DirecTvDevice(MediaPlayerDevice): def turn_on(self): """Turn on the receiver.""" + if self._is_client: + raise NotImplementedError() + _LOGGER.debug("Turn on %s", self._name) self.dtv.key_press('poweron') def turn_off(self): """Turn off the receiver.""" + if self._is_client: + raise NotImplementedError() + _LOGGER.debug("Turn off %s", self._name) self.dtv.key_press('poweroff') From 9fa34f0d77b684a0f4ed05e1d4dc75cb88eda151 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Mon, 19 Nov 2018 03:53:27 -0700 Subject: [PATCH 189/238] Add support for sessions (#18518) Added support for sessions to keep connection open with August portal, improving performance. py-august version increased to 0.7.0 --- homeassistant/components/august.py | 48 ++++++++++++++++++++++++------ requirements_all.txt | 2 +- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index ce8e3d8de11..1f12abd3d4e 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -11,8 +11,9 @@ import voluptuous as vol from requests import RequestException import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT) + CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery from homeassistant.util import Throttle @@ -20,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) _CONFIGURING = {} -REQUIREMENTS = ['py-august==0.6.0'] +REQUIREMENTS = ['py-august==0.7.0'] DEFAULT_TIMEOUT = 10 ACTIVITY_FETCH_LIMIT = 10 @@ -116,7 +117,8 @@ def setup_august(hass, config, api, authenticator): if DOMAIN in _CONFIGURING: hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) - hass.data[DATA_AUGUST] = AugustData(api, authentication.access_token) + hass.data[DATA_AUGUST] = AugustData( + hass, api, authentication.access_token) for component in AUGUST_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, config) @@ -136,9 +138,16 @@ def setup(hass, config): """Set up the August component.""" from august.api import Api from august.authenticator import Authenticator + from requests import Session conf = config[DOMAIN] - api = Api(timeout=conf.get(CONF_TIMEOUT)) + try: + api_http_session = Session() + except RequestException as ex: + _LOGGER.warning("Creating HTTP session failed with: %s", str(ex)) + api_http_session = None + + api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) authenticator = Authenticator( api, @@ -154,8 +163,9 @@ def setup(hass, config): class AugustData: """August data object.""" - def __init__(self, api, access_token): + def __init__(self, hass, api, access_token): """Init August data object.""" + self._hass = hass self._api = api self._access_token = access_token self._doorbells = self._api.get_doorbells(self._access_token) or [] @@ -168,6 +178,22 @@ class AugustData: self._door_state_by_id = {} self._activities_by_id = {} + @callback + def august_api_stop(event): + """Close the API HTTP session.""" + _LOGGER.debug("Closing August HTTP session") + + try: + self._api.http_session.close() + self._api.http_session = None + except RequestException: + pass + _LOGGER.debug("August HTTP session closed.") + + self._hass.bus.listen_once( + EVENT_HOMEASSISTANT_STOP, august_api_stop) + _LOGGER.debug("Registered for HASS stop event") + @property def house_ids(self): """Return a list of house_ids.""" @@ -201,8 +227,11 @@ class AugustData: @Throttle(MIN_TIME_BETWEEN_UPDATES) def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): """Update data object with latest from August API.""" - _LOGGER.debug("Updating device activities") + _LOGGER.debug("Start retrieving device activities") for house_id in self.house_ids: + _LOGGER.debug("Updating device activity for house id %s", + house_id) + activities = self._api.get_house_activities(self._access_token, house_id, limit=limit) @@ -211,6 +240,7 @@ class AugustData: for device_id in device_ids: self._activities_by_id[device_id] = [a for a in activities if a.device_id == device_id] + _LOGGER.debug("Completed retrieving device activities") def get_doorbell_detail(self, doorbell_id): """Return doorbell detail.""" @@ -223,7 +253,7 @@ class AugustData: _LOGGER.debug("Start retrieving doorbell details") for doorbell in self._doorbells: - _LOGGER.debug("Updating status for %s", + _LOGGER.debug("Updating doorbell status for %s", doorbell.device_name) try: detail_by_id[doorbell.device_id] =\ @@ -267,7 +297,7 @@ class AugustData: _LOGGER.debug("Start retrieving door status") for lock in self._locks: - _LOGGER.debug("Updating status for %s", + _LOGGER.debug("Updating door status for %s", lock.device_name) try: @@ -291,7 +321,7 @@ class AugustData: _LOGGER.debug("Start retrieving locks status") for lock in self._locks: - _LOGGER.debug("Updating status for %s", + _LOGGER.debug("Updating lock status for %s", lock.device_name) try: status_by_id[lock.device_id] = self._api.get_lock_status( diff --git a/requirements_all.txt b/requirements_all.txt index 098f8d25c19..7d5e1afed06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -787,7 +787,7 @@ pushetta==1.0.15 pwmled==1.3.0 # homeassistant.components.august -py-august==0.6.0 +py-august==0.7.0 # homeassistant.components.canary py-canary==0.5.0 From 97c493448bd81e44dc55b76d59aa2d1d4b0f5736 Mon Sep 17 00:00:00 2001 From: arigilder <43716164+arigilder@users.noreply.github.com> Date: Mon, 19 Nov 2018 06:06:57 -0500 Subject: [PATCH 190/238] Correct cached stale device tracker handling (#18572) * Fix async addition of stale devices * Add comment to mark_stale * Remove extraneous whitespace --- .../components/device_tracker/__init__.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 82a9fefbb71..a43a7c93bdc 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -373,6 +373,7 @@ class DeviceTracker: for device in self.devices.values(): if (device.track and device.last_update_home) and \ device.stale(now): + device.mark_stale() self.hass.async_create_task(device.async_update_ha_state(True)) async def async_setup_tracked_device(self): @@ -528,9 +529,15 @@ class Device(Entity): Async friendly. """ - return self.last_seen and \ + return self.last_seen is None or \ (now or dt_util.utcnow()) - self.last_seen > self.consider_home + def mark_stale(self): + """Mark the device state as stale.""" + self._state = STATE_NOT_HOME + self.gps = None + self.last_update_home = False + async def async_update(self): """Update state of entity. @@ -550,9 +557,7 @@ class Device(Entity): else: self._state = zone_state.name elif self.stale(): - self._state = STATE_NOT_HOME - self.gps = None - self.last_update_home = False + self.mark_stale() else: self._state = STATE_HOME self.last_update_home = True @@ -563,6 +568,7 @@ class Device(Entity): if not state: return self._state = state.state + self.last_update_home = (state.state == STATE_HOME) for attr, var in ( (ATTR_SOURCE_TYPE, 'source_type'), From 84fd66c8a18330f74ab83cfacb99aa4c6163e2c4 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 19 Nov 2018 12:10:48 +0100 Subject: [PATCH 191/238] Template binary sensor to not track all state changes (#18573) --- .../components/binary_sensor/template.py | 32 +++-- .../components/binary_sensor/test_template.py | 109 ++++++++++++++---- 2 files changed, 111 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 1f386fc2293..d5f8b16e0c1 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -58,10 +58,12 @@ async def async_setup_platform(hass, config, async_add_entities, entity_ids = set() manual_entity_ids = device_config.get(ATTR_ENTITY_ID) - for template in ( - value_template, - icon_template, - entity_picture_template, + invalid_templates = [] + + for tpl_name, template in ( + (CONF_VALUE_TEMPLATE, value_template), + (CONF_ICON_TEMPLATE, icon_template), + (CONF_ENTITY_PICTURE_TEMPLATE, entity_picture_template), ): if template is None: continue @@ -73,6 +75,8 @@ async def async_setup_platform(hass, config, async_add_entities, template_entity_ids = template.extract_entities() if template_entity_ids == MATCH_ALL: entity_ids = MATCH_ALL + # Cut off _template from name + invalid_templates.append(tpl_name[:-9]) elif entity_ids != MATCH_ALL: entity_ids |= set(template_entity_ids) @@ -81,6 +85,14 @@ async def async_setup_platform(hass, config, async_add_entities, elif entity_ids != MATCH_ALL: entity_ids = list(entity_ids) + if invalid_templates: + _LOGGER.warning( + 'Template binary sensor %s has no entity ids configured to' + ' track nor were we able to extract the entities to track' + ' from the %s template(s). This entity will only be able' + ' to be updated manually.', + device, ', '.join(invalid_templates)) + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) delay_on = device_config.get(CONF_DELAY_ON) @@ -132,10 +144,12 @@ class BinarySensorTemplate(BinarySensorDevice): @callback def template_bsensor_startup(event): """Update template on startup.""" - async_track_state_change( - self.hass, self._entities, template_bsensor_state_listener) + if self._entities != MATCH_ALL: + # Track state change only for valid templates + async_track_state_change( + self.hass, self._entities, template_bsensor_state_listener) - self.hass.async_add_job(self.async_check_state) + self.async_check_state() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_bsensor_startup) @@ -233,3 +247,7 @@ class BinarySensorTemplate(BinarySensorDevice): async_track_same_state( self.hass, period, set_state, entity_ids=self._entities, async_check_same_func=lambda *args: self._async_render() == state) + + async def async_update(self): + """Force update of the state from the template.""" + self.async_check_state() diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index f448bcc47a2..a1f97398616 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -1,10 +1,9 @@ """The tests for the Template Binary sensor platform.""" -import asyncio from datetime import timedelta import unittest from unittest import mock -from homeassistant.const import MATCH_ALL +from homeassistant.const import MATCH_ALL, EVENT_HOMEASSISTANT_START from homeassistant import setup from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError @@ -182,7 +181,7 @@ class TestBinarySensorTemplate(unittest.TestCase): self.hass.states.set('sensor.any_state', 'update') self.hass.block_till_done() - assert len(_async_render.mock_calls) > init_calls + assert len(_async_render.mock_calls) == init_calls def test_attributes(self): """Test the attributes.""" @@ -252,8 +251,7 @@ class TestBinarySensorTemplate(unittest.TestCase): run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() -@asyncio.coroutine -def test_template_delay_on(hass): +async def test_template_delay_on(hass): """Test binary sensor template delay on.""" config = { 'binary_sensor': { @@ -269,51 +267,50 @@ def test_template_delay_on(hass): }, }, } - yield from setup.async_setup_component(hass, 'binary_sensor', config) - yield from hass.async_start() + await setup.async_setup_component(hass, 'binary_sensor', config) + await hass.async_start() hass.states.async_set('sensor.test_state', 'on') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' # check with time changes hass.states.async_set('sensor.test_state', 'off') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' hass.states.async_set('sensor.test_state', 'on') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' hass.states.async_set('sensor.test_state', 'off') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' -@asyncio.coroutine -def test_template_delay_off(hass): +async def test_template_delay_off(hass): """Test binary sensor template delay off.""" config = { 'binary_sensor': { @@ -330,44 +327,110 @@ def test_template_delay_off(hass): }, } hass.states.async_set('sensor.test_state', 'on') - yield from setup.async_setup_component(hass, 'binary_sensor', config) - yield from hass.async_start() + await setup.async_setup_component(hass, 'binary_sensor', config) + await hass.async_start() hass.states.async_set('sensor.test_state', 'off') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' # check with time changes hass.states.async_set('sensor.test_state', 'on') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' hass.states.async_set('sensor.test_state', 'off') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' hass.states.async_set('sensor.test_state', 'on') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' + + +async def test_no_update_template_match_all(hass, caplog): + """Test that we do not update sensors that match on all.""" + hass.states.async_set('binary_sensor.test_sensor', 'true') + + await setup.async_setup_component(hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'all_state': { + 'value_template': '{{ "true" }}', + }, + 'all_icon': { + 'value_template': + '{{ states.binary_sensor.test_sensor.state }}', + 'icon_template': '{{ 1 + 1 }}', + }, + 'all_entity_picture': { + 'value_template': + '{{ states.binary_sensor.test_sensor.state }}', + 'entity_picture_template': '{{ 1 + 1 }}', + }, + } + } + }) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 + assert ('Template binary sensor all_state has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the value template') in caplog.text + assert ('Template binary sensor all_icon has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the icon template') in caplog.text + assert ('Template binary sensor all_entity_picture has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the entity_picture template') in caplog.text + + assert hass.states.get('binary_sensor.all_state').state == 'off' + assert hass.states.get('binary_sensor.all_icon').state == 'off' + assert hass.states.get('binary_sensor.all_entity_picture').state == 'off' + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert hass.states.get('binary_sensor.all_state').state == 'on' + assert hass.states.get('binary_sensor.all_icon').state == 'on' + assert hass.states.get('binary_sensor.all_entity_picture').state == 'on' + + hass.states.async_set('binary_sensor.test_sensor', 'false') + await hass.async_block_till_done() + + assert hass.states.get('binary_sensor.all_state').state == 'on' + assert hass.states.get('binary_sensor.all_icon').state == 'on' + assert hass.states.get('binary_sensor.all_entity_picture').state == 'on' + + await hass.helpers.entity_component.async_update_entity( + 'binary_sensor.all_state') + await hass.helpers.entity_component.async_update_entity( + 'binary_sensor.all_icon') + await hass.helpers.entity_component.async_update_entity( + 'binary_sensor.all_entity_picture') + + assert hass.states.get('binary_sensor.all_state').state == 'on' + assert hass.states.get('binary_sensor.all_icon').state == 'off' + assert hass.states.get('binary_sensor.all_entity_picture').state == 'off' From c1ca7beea11a6a9ee5722f4afb9e1a8ac420df8c Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 19 Nov 2018 12:52:21 +0100 Subject: [PATCH 192/238] Support for Point component (#17466) * Minut Point support * tox tests for Point * config flow fixes * fixes requested by @MartinHjelmare * swedish translation :) * fix tests --- .coveragerc | 4 + .../components/binary_sensor/point.py | 103 ++++++ .../components/point/.translations/en.json | 33 ++ .../components/point/.translations/sv.json | 33 ++ homeassistant/components/point/__init__.py | 306 ++++++++++++++++++ homeassistant/components/point/config_flow.py | 189 +++++++++++ homeassistant/components/point/const.py | 15 + homeassistant/components/point/strings.json | 32 ++ homeassistant/components/sensor/point.py | 68 ++++ homeassistant/config_entries.py | 1 + requirements_all.txt | 3 + tests/components/point/__init__.py | 1 + tests/components/point/test_config_flow.py | 147 +++++++++ 13 files changed, 935 insertions(+) create mode 100644 homeassistant/components/binary_sensor/point.py create mode 100644 homeassistant/components/point/.translations/en.json create mode 100644 homeassistant/components/point/.translations/sv.json create mode 100644 homeassistant/components/point/__init__.py create mode 100644 homeassistant/components/point/config_flow.py create mode 100644 homeassistant/components/point/const.py create mode 100644 homeassistant/components/point/strings.json create mode 100644 homeassistant/components/sensor/point.py create mode 100644 tests/components/point/__init__.py create mode 100644 tests/components/point/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2762dffbeb1..a4fd6ea1c2e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -262,6 +262,10 @@ omit = homeassistant/components/pilight.py homeassistant/components/*/pilight.py + homeassistant/components/point/__init__.py + homeassistant/components/point/const.py + homeassistant/components/*/point.py + homeassistant/components/switch/qwikswitch.py homeassistant/components/light/qwikswitch.py diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py new file mode 100644 index 00000000000..a2ed9eabebf --- /dev/null +++ b/homeassistant/components/binary_sensor/point.py @@ -0,0 +1,103 @@ +""" +Support for Minut Point. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.point/ +""" + +import logging + +from homeassistant.components.point import MinutPointEntity +from homeassistant.components.point.const import ( + DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +EVENTS = { + 'battery': # On means low, Off means normal + ('battery_low', ''), + 'button_press': # On means the button was pressed, Off means normal + ('short_button_press', ''), + 'cold': # On means cold, Off means normal + ('temperature_low', 'temperature_risen_normal'), + 'connectivity': # On means connected, Off means disconnected + ('device_online', 'device_offline'), + 'dry': # On means too dry, Off means normal + ('humidity_low', 'humidity_risen_normal'), + 'heat': # On means hot, Off means normal + ('temperature_high', 'temperature_dropped_normal'), + 'moisture': # On means wet, Off means dry + ('humidity_high', 'humidity_dropped_normal'), + 'sound': # On means sound detected, Off means no sound (clear) + ('avg_sound_high', 'sound_level_dropped_normal'), + 'tamper': # On means the point was removed or attached + ('tamper', ''), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Point's binary sensors based on a config entry.""" + device_id = config_entry.data[NEW_DEVICE] + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities((MinutPointBinarySensor(client, device_id, device_class) + for device_class in EVENTS), True) + + +class MinutPointBinarySensor(MinutPointEntity): + """The platform class required by Home Assistant.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + super().__init__(point_client, device_id, device_class) + + self._async_unsub_hook_dispatcher_connect = None + self._events = EVENTS[device_class] + self._is_on = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( + self.hass, SIGNAL_WEBHOOK, self._webhook_event) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + await super().async_will_remove_from_hass() + if self._async_unsub_hook_dispatcher_connect: + self._async_unsub_hook_dispatcher_connect() + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + if not self.is_updated: + return + if self._events[0] in self.device.ongoing_events: + self._is_on = True + else: + self._is_on = None + self.async_schedule_update_ha_state() + + @callback + def _webhook_event(self, data, webhook): + """Process new event from the webhook.""" + if self.device.webhook != webhook: + return + _type = data.get('event', {}).get('type') + if _type not in self._events: + return + _LOGGER.debug("Recieved webhook: %s", _type) + if _type == self._events[0]: + self._is_on = True + if _type == self._events[1]: + self._is_on = None + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return the state of the binary sensor.""" + if self.device_class == 'connectivity': + # connectivity is the other way around. + return not self._is_on + return self._is_on diff --git a/homeassistant/components/point/.translations/en.json b/homeassistant/components/point/.translations/en.json new file mode 100644 index 00000000000..fed892113c3 --- /dev/null +++ b/homeassistant/components/point/.translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "Minut Point", + "step": { + "user": { + "title": "Authentication Provider", + "description": "Pick via which authentication provider you want to authenticate with Point.", + "data": { + "flow_impl": "Provider" + } + }, + "auth": { + "title": "Authenticate Point", + "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})" + } + }, + "create_entry": { + "default": "Successfully authenticated with Minut for your Point device(s)" + }, + "error": { + "no_token": "Not authenticated with Minut", + "follow_link": "Please follow the link and authenticate before pressing Submit" + }, + "abort": { + "already_setup": "You can only configure a Point account.", + "external_setup": "Point successfully configured from another flow.", + "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/).", + "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_fail": "Unknown error generating an authorize url." + } + } +} + \ No newline at end of file diff --git a/homeassistant/components/point/.translations/sv.json b/homeassistant/components/point/.translations/sv.json new file mode 100644 index 00000000000..6464434eda4 --- /dev/null +++ b/homeassistant/components/point/.translations/sv.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "Minut Point", + "step": { + "user": { + "title": "Autentiseringsleverant\u00f6r", + "description": "V\u00e4lj den autentiseringsleverant\u00f6r som du vill autentisera med mot Point.", + "data": { + "flow_impl": "Leverant\u00f6r" + } + }, + "auth": { + "title": "Autentisera Point", + "description": "F\u00f6lj l\u00e4nken nedan och klicka p\u00e5 Accept f\u00f6r att tilll\u00e5ta tillg\u00e5ng till ditt Minut konto, kom d\u00f6refter tillbaka hit och kicka p\u00e5 Submit nedan.\n\n[L\u00e4nk]({authorization_url})" + } + }, + "create_entry": { + "default": "Autentiserad med Minut f\u00f6r era Point enheter." + }, + "error": { + "no_token": "Inte autentiserad hos Minut", + "follow_link": "F\u00f6lj l\u00e4nken och autentisera innan du kickar på Submit" + }, + "abort": { + "already_setup": "Du kan endast konfigurera ett Point-konto.", + "external_setup": "Point har lyckats konfigureras fr\u00e5n ett annat fl\u00f6de.", + "no_flows": "Du m\u00e5ste konfigurera Nest innan du kan autentisera med det. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/point/).", + "authorize_url_timeout": "Timeout vid generering av en autentisieringsadress.", + "authorize_url_fail": "Ok\u00e4nt fel vid generering av autentisieringsadress." + } + } +} + \ No newline at end of file diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py new file mode 100644 index 00000000000..fcbd5ddb064 --- /dev/null +++ b/homeassistant/components/point/__init__.py @@ -0,0 +1,306 @@ +""" +Support for Minut Point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/point/ +""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp + +from . import config_flow # noqa pylint_disable=unused-import +from .const import ( + CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, + SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) + +REQUIREMENTS = ['pypoint==1.0.5'] +DEPENDENCIES = ['webhook'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: + vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + }) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Minut Point component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + config_flow.register_flow_implementation( + hass, DOMAIN, conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET]) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + )) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Point from a config entry.""" + from pypoint import PointSession + + def token_saver(token): + _LOGGER.debug('Saving updated token') + entry.data[CONF_TOKEN] = token + hass.config_entries.async_update_entry(entry, data={**entry.data}) + + # Force token update. + entry.data[CONF_TOKEN]['expires_in'] = -1 + session = PointSession( + entry.data['refresh_args']['client_id'], + token=entry.data[CONF_TOKEN], + auto_refresh_kwargs=entry.data['refresh_args'], + token_saver=token_saver, + ) + + if not session.is_authorized: + _LOGGER.error('Authentication Error') + return False + + await async_setup_webhook(hass, entry, session) + client = MinutPointClient(hass, entry, session) + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) + await client.update() + + return True + + +async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, + session): + """Set up a webhook to handle binary sensor events.""" + if CONF_WEBHOOK_ID not in entry.data: + entry.data[CONF_WEBHOOK_ID] = \ + hass.components.webhook.async_generate_id() + entry.data[CONF_WEBHOOK_URL] = \ + hass.components.webhook.async_generate_url( + entry.data[CONF_WEBHOOK_ID]) + _LOGGER.info('Registering new webhook at: %s', + entry.data[CONF_WEBHOOK_URL]) + hass.config_entries.async_update_entry( + entry, data={ + **entry.data, + }) + session.update_webhook(entry.data[CONF_WEBHOOK_URL], + entry.data[CONF_WEBHOOK_ID]) + + hass.components.webhook.async_register(entry.data[CONF_WEBHOOK_ID], + handle_webhook) + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + client = hass.data[DOMAIN].pop(entry.entry_id) + client.remove_webhook() + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + for component in ('binary_sensor', 'sensor'): + await hass.config_entries.async_forward_entry_unload( + entry, component) + + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + try: + data = await request.json() + _LOGGER.debug("Webhook %s: %s", webhook_id, data) + except ValueError: + return None + + if isinstance(data, dict): + data['webhook_id'] = webhook_id + async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get('hook_id')) + hass.bus.async_fire(EVENT_RECEIVED, data) + + +class MinutPointClient(): + """Get the latest data and update the states.""" + + def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry, + session): + """Initialize the Minut data object.""" + self._known_devices = [] + self._hass = hass + self._config_entry = config_entry + self._is_available = True + self._client = session + + async_track_time_interval(self._hass, self.update, SCAN_INTERVAL) + + async def update(self, *args): + """Periodically poll the cloud for current state.""" + await self._sync() + + async def _sync(self): + """Update local list of devices.""" + if not self._client.update() and self._is_available: + self._is_available = False + _LOGGER.warning("Device is unavailable") + return + + self._is_available = True + for device in self._client.devices: + if device.device_id not in self._known_devices: + # A way to communicate the device_id to entry_setup, + # can this be done nicer? + self._config_entry.data[NEW_DEVICE] = device.device_id + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'sensor') + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'binary_sensor') + self._known_devices.append(device.device_id) + del self._config_entry.data[NEW_DEVICE] + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) + + def device(self, device_id): + """Return device representation.""" + return self._client.device(device_id) + + def is_available(self, device_id): + """Return device availability.""" + return device_id in self._client.device_ids + + def remove_webhook(self): + """Remove the session webhook.""" + return self._client.remove_webhook() + + +class MinutPointEntity(Entity): + """Base Entity used by the sensors.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + self._async_unsub_dispatcher_connect = None + self._client = point_client + self._id = device_id + self._name = self.device.name + self._device_class = device_class + self._updated = utc_from_timestamp(0) + self._value = None + + def __str__(self): + """Return string representation of device.""" + return "MinutPoint {}".format(self.name) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + _LOGGER.debug('Created device %s', self) + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback) + self._update_callback() + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + pass + + @property + def available(self): + """Return true if device is not offline.""" + return self._client.is_available(self.device_id) + + @property + def device(self): + """Return the representation of the device.""" + return self._client.device(self.device_id) + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def device_id(self): + """Return the id of the device.""" + return self._id + + @property + def device_state_attributes(self): + """Return status of device.""" + attrs = self.device.device_status + attrs['last_heard_from'] = \ + as_local(self.last_update).strftime("%Y-%m-%d %H:%M:%S") + return attrs + + @property + def device_info(self): + """Return a device description for device registry.""" + device = self.device.device + return { + 'connections': {('mac', device['device_mac'])}, + 'identifieres': device['device_id'], + 'manufacturer': 'Minut', + 'model': 'Point v{}'.format(device['hardware_version']), + 'name': device['description'], + 'sw_version': device['firmware']['installed'], + } + + @property + def name(self): + """Return the display name of this device.""" + return "{} {}".format(self._name, self.device_class.capitalize()) + + @property + def is_updated(self): + """Return true if sensor have been updated.""" + return self.last_update > self._updated + + @property + def last_update(self): + """Return the last_update time for the device.""" + last_update = parse_datetime(self.device.last_update) + return last_update + + @property + def should_poll(self): + """No polling needed for point.""" + return False + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return 'point.{}-{}'.format(self._id, self.device_class) + + @property + def value(self): + """Return the sensor value.""" + return self._value diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py new file mode 100644 index 00000000000..8cda30c7171 --- /dev/null +++ b/homeassistant/components/point/config_flow.py @@ -0,0 +1,189 @@ +"""Config flow for Minut Point.""" +import asyncio +from collections import OrderedDict +import logging + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback + +from .const import CLIENT_ID, CLIENT_SECRET, DOMAIN + +AUTH_CALLBACK_PATH = '/api/minut' +AUTH_CALLBACK_NAME = 'api:minut' + +DATA_FLOW_IMPL = 'point_flow_implementation' + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, domain, client_id, client_secret): + """Register a flow implementation. + + domain: Domain of the component responsible for the implementation. + name: Name of the component. + client_id: Client id. + client_secret: Client secret. + """ + if DATA_FLOW_IMPL not in hass.data: + hass.data[DATA_FLOW_IMPL] = OrderedDict() + + hass.data[DATA_FLOW_IMPL][domain] = { + CLIENT_ID: client_id, + CLIENT_SECRET: client_secret, + } + + +@config_entries.HANDLERS.register('point') +class PointFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNETION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize flow.""" + self.flow_impl = None + + async def async_step_import(self, user_input=None): + """Handle external yaml configuration.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + self.flow_impl = DOMAIN + + return await self.async_step_auth() + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + flows = self.hass.data.get(DATA_FLOW_IMPL, {}) + + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if not flows: + _LOGGER.debug("no flows") + return self.async_abort(reason='no_flows') + + if len(flows) == 1: + self.flow_impl = list(flows)[0] + return await self.async_step_auth() + + if user_input is not None: + self.flow_impl = user_input['flow_impl'] + return await self.async_step_auth() + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required('flow_impl'): + vol.In(list(flows)) + })) + + async def async_step_auth(self, user_input=None): + """Create an entry for auth.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='external_setup') + + errors = {} + + if user_input is not None: + errors['base'] = 'follow_link' + + try: + with async_timeout.timeout(10): + url = await self._get_authorization_url() + except asyncio.TimeoutError: + return self.async_abort(reason='authorize_url_timeout') + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error generating auth url") + return self.async_abort(reason='authorize_url_fail') + + return self.async_show_form( + step_id='auth', + description_placeholders={'authorization_url': url}, + errors=errors, + ) + + async def _get_authorization_url(self): + """Create Minut Point session and get authorization url.""" + from pypoint import PointSession + flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] + client_id = flow[CLIENT_ID] + client_secret = flow[CLIENT_SECRET] + point_session = PointSession( + client_id, client_secret=client_secret) + + self.hass.http.register_view(MinutAuthCallbackView()) + + return point_session.get_authorization_url + + async def async_step_code(self, code=None): + """Received code for authentication.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if code is None: + return self.async_abort(reason='no_code') + + _LOGGER.debug("Should close all flows below %s", + self.hass.config_entries.flow.async_progress()) + # Remove notification if no other discovery config entries in progress + + return await self._async_create_session(code) + + async def _async_create_session(self, code): + """Create point session and entries.""" + from pypoint import PointSession + flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] + client_id = flow[CLIENT_ID] + client_secret = flow[CLIENT_SECRET] + point_session = PointSession( + client_id, + client_secret=client_secret, + ) + token = await self.hass.async_add_executor_job( + point_session.get_access_token, code) + _LOGGER.debug("Got new token") + if not point_session.is_authorized: + _LOGGER.error('Authentication Error') + return self.async_abort(reason='auth_error') + + _LOGGER.info('Successfully authenticated Point') + user_email = point_session.user().get('email') or "" + + return self.async_create_entry( + title=user_email, + data={ + 'token': token, + 'refresh_args': { + 'client_id': client_id, + 'client_secret': client_secret + } + }, + ) + + +class MinutAuthCallbackView(HomeAssistantView): + """Minut Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + @staticmethod + async def get(request): + """Receive authorization code.""" + hass = request.app['hass'] + if 'code' in request.query: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': 'code'}, + data=request.query['code'], + )) + return "OK!" diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py new file mode 100644 index 00000000000..4ef21b57cd9 --- /dev/null +++ b/homeassistant/components/point/const.py @@ -0,0 +1,15 @@ +"""Define constants for the Point component.""" +from datetime import timedelta + +DOMAIN = 'point' +CLIENT_ID = 'client_id' +CLIENT_SECRET = 'client_secret' + + +SCAN_INTERVAL = timedelta(minutes=1) + +CONF_WEBHOOK_URL = 'webhook_url' +EVENT_RECEIVED = 'point_webhook_received' +SIGNAL_UPDATE_ENTITY = 'point_update' +SIGNAL_WEBHOOK = 'point_webhook' +NEW_DEVICE = 'new_device' diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json new file mode 100644 index 00000000000..642a61a5f9d --- /dev/null +++ b/homeassistant/components/point/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "title": "Minut Point", + "step": { + "user": { + "title": "Authentication Provider", + "description": "Pick via which authentication provider you want to authenticate with Point.", + "data": { + "flow_impl": "Provider" + } + }, + "auth": { + "title": "Authenticate Point", + "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})" + } + }, + "create_entry": { + "default": "Successfully authenticated with Minut for your Point device(s)" + }, + "error": { + "no_token": "Not authenticated with Minut", + "follow_link": "Please follow the link and authenticate before pressing Submit" + }, + "abort": { + "already_setup": "You can only configure a Point account.", + "external_setup": "Point successfully configured from another flow.", + "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/).", + "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_fail": "Unknown error generating an authorize url." + } + } +} diff --git a/homeassistant/components/sensor/point.py b/homeassistant/components/sensor/point.py new file mode 100644 index 00000000000..0c099c8873e --- /dev/null +++ b/homeassistant/components/sensor/point.py @@ -0,0 +1,68 @@ +""" +Support for Minut Point. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.point/ +""" +import logging + +from homeassistant.components.point import MinutPointEntity +from homeassistant.components.point.const import ( + DOMAIN as POINT_DOMAIN, NEW_DEVICE) +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.util.dt import parse_datetime + +_LOGGER = logging.getLogger(__name__) + +DEVICE_CLASS_SOUND = 'sound_level' + +SENSOR_TYPES = { + DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS), + DEVICE_CLASS_PRESSURE: (None, 0, 'hPa'), + DEVICE_CLASS_HUMIDITY: (None, 1, '%'), + DEVICE_CLASS_SOUND: ('mdi:ear-hearing', 1, 'dBa'), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Point's sensors based on a config entry.""" + device_id = config_entry.data[NEW_DEVICE] + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities((MinutPointSensor(client, device_id, sensor_type) + for sensor_type in SENSOR_TYPES), True) + + +class MinutPointSensor(MinutPointEntity): + """The platform class required by Home Assistant.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + super().__init__(point_client, device_id, device_class) + self._device_prop = SENSOR_TYPES[device_class] + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + if self.is_updated: + _LOGGER.debug('Update sensor value for %s', self) + self._value = self.device.sensor(self.device_class) + self._updated = parse_datetime(self.device.last_update) + self.async_schedule_update_ha_state() + + @property + def icon(self): + """Return the icon representation.""" + return self._device_prop[0] + + @property + def state(self): + """Return the state of the sensor.""" + return round(self.value, self._device_prop[1]) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._device_prop[2] diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 6669d5240d8..42bc8b089da 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ FLOWS = [ 'mqtt', 'nest', 'openuv', + 'point', 'rainmachine', 'simplisafe', 'smhi', diff --git a/requirements_all.txt b/requirements_all.txt index 7d5e1afed06..bd63bf2480f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1095,6 +1095,9 @@ pyowm==2.9.0 # homeassistant.components.media_player.pjlink pypjlink2==1.2.0 +# homeassistant.components.point +pypoint==1.0.5 + # homeassistant.components.sensor.pollen pypollencom==2.2.2 diff --git a/tests/components/point/__init__.py b/tests/components/point/__init__.py new file mode 100644 index 00000000000..9fb6eea9ac7 --- /dev/null +++ b/tests/components/point/__init__.py @@ -0,0 +1 @@ +"""Tests for the Point component.""" diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py new file mode 100644 index 00000000000..cf9f3b2dbdd --- /dev/null +++ b/tests/components/point/test_config_flow.py @@ -0,0 +1,147 @@ +"""Tests for the Point config flow.""" +import asyncio +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.point import DOMAIN, config_flow + +from tests.common import MockDependency, mock_coro + + +def init_config_flow(hass, side_effect=None): + """Init a configuration flow.""" + config_flow.register_flow_implementation(hass, DOMAIN, 'id', 'secret') + flow = config_flow.PointFlowHandler() + flow._get_authorization_url = Mock( # pylint: disable=W0212 + return_value=mock_coro('https://example.com'), + side_effect=side_effect) + flow.hass = hass + return flow + + +@pytest.fixture +def is_authorized(): + """Set PointSession authorized.""" + return True + + +@pytest.fixture +def mock_pypoint(is_authorized): # pylint: disable=W0621 + """Mock pypoint.""" + with MockDependency('pypoint') as mock_pypoint_: + mock_pypoint_.PointSession().get_access_token.return_value = { + 'access_token': 'boo' + } + mock_pypoint_.PointSession().is_authorized = is_authorized + mock_pypoint_.PointSession().user.return_value = { + 'email': 'john.doe@example.com' + } + yield mock_pypoint_ + + +async def test_abort_if_no_implementation_registered(hass): + """Test we abort if no implementation is registered.""" + flow = config_flow.PointFlowHandler() + flow.hass = hass + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_flows' + + +async def test_abort_if_already_setup(hass): + """Test we abort if Point is already setup.""" + flow = init_config_flow(hass) + + with patch.object(hass.config_entries, 'async_entries', return_value=[{}]): + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + with patch.object(hass.config_entries, 'async_entries', return_value=[{}]): + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_full_flow_implementation(hass, mock_pypoint): # noqa pylint: disable=W0621 + """Test registering an implementation and finishing flow works.""" + config_flow.register_flow_implementation(hass, 'test-other', None, None) + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_user({'flow_impl': 'test'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + assert result['description_placeholders'] == { + 'authorization_url': 'https://example.com', + } + + result = await flow.async_step_code('123ABC') + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['refresh_args'] == { + 'client_id': 'id', + 'client_secret': 'secret' + } + assert result['title'] == 'john.doe@example.com' + assert result['data']['token'] == {'access_token': 'boo'} + + +async def test_step_import(hass, mock_pypoint): # pylint: disable=W0621 + """Test that we trigger import when configuring with client.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +@pytest.mark.parametrize('is_authorized', [False]) +async def test_wrong_code_flow_implementation(hass, mock_pypoint): # noqa pylint: disable=W0621 + """Test wrong code.""" + flow = init_config_flow(hass) + + result = await flow.async_step_code('123ABC') + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'auth_error' + + +async def test_not_pick_implementation_if_only_one(hass): + """Test we allow picking implementation if we have one flow_imp.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +async def test_abort_if_timeout_generating_auth_url(hass): + """Test we abort if generating authorize url fails.""" + flow = init_config_flow(hass, side_effect=asyncio.TimeoutError) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_timeout' + + +async def test_abort_if_exception_generating_auth_url(hass): + """Test we abort if generating authorize url blows up.""" + flow = init_config_flow(hass, side_effect=ValueError) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_fail' + + +async def test_abort_no_code(hass): + """Test if no code is given to step_code.""" + flow = init_config_flow(hass) + + result = await flow.async_step_code() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_code' From 90f3f2b1e75a8cd5161ee6e4d1b8fff91556b87f Mon Sep 17 00:00:00 2001 From: bw3 Date: Mon, 19 Nov 2018 07:47:52 -0500 Subject: [PATCH 193/238] Fix for epson state not updating (#18357) * Fixed update method name * Update epson.py --- homeassistant/components/media_player/epson.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/epson.py b/homeassistant/components/media_player/epson.py index 46beb4487fd..bb1618f2351 100644 --- a/homeassistant/components/media_player/epson.py +++ b/homeassistant/components/media_player/epson.py @@ -75,7 +75,7 @@ async def async_setup_platform( if service.service == SERVICE_SELECT_CMODE: cmode = service.data.get(ATTR_CMODE) await device.select_cmode(cmode) - await device.update() + device.async_schedule_update_ha_state(True) epson_schema = MEDIA_PLAYER_SCHEMA.extend({ vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET)) @@ -102,7 +102,7 @@ class EpsonProjector(MediaPlayerDevice): self._volume = None self._state = None - async def update(self): + async def async_update(self): """Update state of device.""" from epson_projector.const import ( EPSON_CODES, POWER, CMODE, CMODE_LIST, SOURCE, VOLUME, BUSY, From fc4dd4e51f634f3305facc5f57915cbe0ac2c4ca Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Mon, 19 Nov 2018 14:26:08 +0100 Subject: [PATCH 194/238] Update pyhomematic to 0.1.52 and add features for lights (#18499) * Update pyhomematic and add features for lights * Lint * Lint * Update homematic.py * Update homematic.py --- .../components/homematic/__init__.py | 9 ++-- homeassistant/components/light/homematic.py | 48 ++++++++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 4343bcfbc08..d5336217221 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.51'] +REQUIREMENTS = ['pyhomematic==0.1.52'] _LOGGER = logging.getLogger(__name__) @@ -64,8 +64,9 @@ HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', 'RFSiren', 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic', - 'IPKeySwitchPowermeter'], - DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer'], + 'IPKeySwitchPowermeter', 'IPGarage'], + DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer', 'IPDimmer', + 'ColorEffectLight'], DISCOVER_SENSORS: [ 'SwitchPowermeter', 'Motion', 'MotionV2', 'RemoteMotion', 'MotionIP', 'ThermostatWall', 'AreaThermostat', 'RotaryHandleSensor', @@ -76,7 +77,7 @@ HM_DEVICE_TYPES = { 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', - 'IPWeatherSensorBasic', 'IPBrightnessSensor'], + 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 9a7baa713a3..de11c96f8b7 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -8,8 +8,8 @@ import logging from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.const import STATE_UNKNOWN + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_COLOR, + ATTR_EFFECT, SUPPORT_EFFECT, Light) _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ class HMLight(HMDevice, Light): def brightness(self): """Return the brightness of this light between 0..255.""" # Is dimmer? - if self._state == "LEVEL": + if self._state == 'LEVEL': return int(self._hm_get_state() * 255) return None @@ -53,16 +53,47 @@ class HMLight(HMDevice, Light): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_HOMEMATIC + if 'COLOR' in self._hmdevice.WRITENODE: + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_EFFECT + return SUPPORT_BRIGHTNESS + + @property + def hs_color(self): + """Return the hue and saturation color value [float, float].""" + if not self.supported_features & SUPPORT_COLOR: + return None + hue, sat = self._hmdevice.get_hs_color() + return hue*360.0, sat*100.0 + + @property + def effect_list(self): + """Return the list of supported effects.""" + if not self.supported_features & SUPPORT_EFFECT: + return None + return self._hmdevice.get_effect_list() + + @property + def effect(self): + """Return the current color change program of the light.""" + if not self.supported_features & SUPPORT_EFFECT: + return None + return self._hmdevice.get_effect() def turn_on(self, **kwargs): - """Turn the light on.""" + """Turn the light on and/or change color or color effect settings.""" if ATTR_BRIGHTNESS in kwargs and self._state == "LEVEL": percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255 self._hmdevice.set_level(percent_bright, self._channel) - else: + elif ATTR_HS_COLOR not in kwargs and ATTR_EFFECT not in kwargs: self._hmdevice.on(self._channel) + if ATTR_HS_COLOR in kwargs: + self._hmdevice.set_hs_color( + hue=kwargs[ATTR_HS_COLOR][0]/360.0, + saturation=kwargs[ATTR_HS_COLOR][1]/100.0) + if ATTR_EFFECT in kwargs: + self._hmdevice.set_effect(kwargs[ATTR_EFFECT]) + def turn_off(self, **kwargs): """Turn the light off.""" self._hmdevice.off(self._channel) @@ -71,4 +102,7 @@ class HMLight(HMDevice, Light): """Generate a data dict (self._data) from the Homematic metadata.""" # Use LEVEL self._state = "LEVEL" - self._data.update({self._state: STATE_UNKNOWN}) + self._data[self._state] = None + + if self.supported_features & SUPPORT_COLOR: + self._data.update({"COLOR": None, "PROGRAM": None}) diff --git a/requirements_all.txt b/requirements_all.txt index bd63bf2480f..9e129753dbb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -965,7 +965,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.51 +pyhomematic==0.1.52 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f904a96b520..0ff8df5e811 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -162,7 +162,7 @@ pydeconz==47 pydispatcher==2.0.5 # homeassistant.components.homematic -pyhomematic==0.1.51 +pyhomematic==0.1.52 # homeassistant.components.litejet pylitejet==0.1 From 01953ab46b88b66dd12a3237f778bc37b1873dfc Mon Sep 17 00:00:00 2001 From: Oleksii Serdiuk Date: Mon, 19 Nov 2018 14:48:52 +0100 Subject: [PATCH 195/238] Darksky: Expose missing conditions for day 0 forecast (#18312) Dark Sky Sensor didn't expose conditions for day 0 (today forecast) that had the same name as current conditions. With this change all conditions form day 0 (today) forecast are exposed the same way as conditions for the rest of the days (1..7): as `dark_sky__`. As a consequence, conditions for day 0 that were already exposed now have `_0` suffix. This actually improves the code by removing most of special handling, based on condition name. To get day 0 conditions the user now has to add `- 0` to `forecast` configuration parameter. Conditions, for which suffix `_0` appeared: `precip_accumulation`, `temperature_high`, `temperature_low`, `apparent_temperature_high`, `apparent_temperature_low`, `precip_intensity_max`, `moon_phase`. This is a breaking change! Closes #18205 --- homeassistant/components/sensor/darksky.py | 55 +++++++++------------- tests/components/sensor/test_darksky.py | 10 ++-- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index 63744b7cd28..d4546d7b721 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -43,7 +43,8 @@ DEPRECATED_SENSOR_TYPES = { # Sensor types are defined like so: # Name, si unit, us unit, ca unit, uk unit, uk2 unit SENSOR_TYPES = { - 'summary': ['Summary', None, None, None, None, None, None, ['daily']], + 'summary': ['Summary', None, None, None, None, None, None, + ['currently', 'hourly', 'daily']], 'minutely_summary': ['Minutely Summary', None, None, None, None, None, None, []], 'hourly_summary': ['Hourly Summary', None, None, None, None, None, None, @@ -72,7 +73,7 @@ SENSOR_TYPES = { ['hourly', 'daily']], 'temperature': ['Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', - ['currently', 'hourly']], + ['currently', 'hourly', 'daily']], 'apparent_temperature': ['Apparent Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly']], @@ -98,15 +99,13 @@ SENSOR_TYPES = { ['currently', 'hourly', 'daily']], 'apparent_temperature_max': ['Daily High Apparent Temperature', '°C', '°F', '°C', '°C', '°C', - 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + 'mdi:thermometer', ['daily']], 'apparent_temperature_high': ["Daytime High Apparent Temperature", '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['daily']], 'apparent_temperature_min': ['Daily Low Apparent Temperature', '°C', '°F', '°C', '°C', '°C', - 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + 'mdi:thermometer', ['daily']], 'apparent_temperature_low': ['Overnight Low Apparent Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['daily']], @@ -124,8 +123,7 @@ SENSOR_TYPES = { ['daily']], 'precip_intensity_max': ['Daily Max Precip Intensity', 'mm/h', 'in', 'mm/h', 'mm/h', 'mm/h', - 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + 'mdi:thermometer', ['daily']], 'uv_index': ['UV Index', UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX, 'mdi:weather-sunny', @@ -180,7 +178,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=300)): ( vol.All(cv.time_period, cv.positive_timedelta)), vol.Optional(CONF_FORECAST): - vol.All(cv.ensure_list, [vol.Range(min=1, max=7)]), + vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]), }) @@ -215,7 +213,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for variable in config[CONF_MONITORED_CONDITIONS]: if variable in DEPRECATED_SENSOR_TYPES: _LOGGER.warning("Monitored condition %s is deprecated", variable) - sensors.append(DarkSkySensor(forecast_data, variable, name)) + if (not SENSOR_TYPES[variable][7] or + 'currently' in SENSOR_TYPES[variable][7]): + sensors.append(DarkSkySensor(forecast_data, variable, name)) if forecast is not None and 'daily' in SENSOR_TYPES[variable][7]: for forecast_day in forecast: sensors.append(DarkSkySensor( @@ -227,7 +227,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DarkSkySensor(Entity): """Implementation of a Dark Sky sensor.""" - def __init__(self, forecast_data, sensor_type, name, forecast_day=0): + def __init__(self, forecast_data, sensor_type, name, forecast_day=None): """Initialize the sensor.""" self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] @@ -241,7 +241,7 @@ class DarkSkySensor(Entity): @property def name(self): """Return the name of the sensor.""" - if self.forecast_day == 0: + if self.forecast_day is None: return '{} {}'.format(self.client_name, self._name) return '{} {} {}'.format( @@ -318,30 +318,18 @@ class DarkSkySensor(Entity): hourly = self.forecast_data.data_hourly self._state = getattr(hourly, 'summary', '') self._icon = getattr(hourly, 'icon', '') - elif self.forecast_day > 0 or ( - self.type in ['daily_summary', - 'temperature_min', - 'temperature_low', - 'temperature_max', - 'temperature_high', - 'apparent_temperature_min', - 'apparent_temperature_low', - 'apparent_temperature_max', - 'apparent_temperature_high', - 'precip_intensity_max', - 'precip_accumulation', - 'moon_phase']): + elif self.type == 'daily_summary': self.forecast_data.update_daily() daily = self.forecast_data.data_daily - if self.type == 'daily_summary': - self._state = getattr(daily, 'summary', '') - self._icon = getattr(daily, 'icon', '') + self._state = getattr(daily, 'summary', '') + self._icon = getattr(daily, 'icon', '') + elif self.forecast_day is not None: + self.forecast_data.update_daily() + daily = self.forecast_data.data_daily + if hasattr(daily, 'data'): + self._state = self.get_state(daily.data[self.forecast_day]) else: - if hasattr(daily, 'data'): - self._state = self.get_state( - daily.data[self.forecast_day]) - else: - self._state = 0 + self._state = 0 else: self.forecast_data.update_currently() currently = self.forecast_data.data_currently @@ -366,6 +354,7 @@ class DarkSkySensor(Entity): # percentages if self.type in ['precip_probability', 'cloud_cover', 'humidity']: return round(state * 100, 1) + if self.type in ['dew_point', 'temperature', 'apparent_temperature', 'temperature_low', 'apparent_temperature_low', 'temperature_min', 'apparent_temperature_min', diff --git a/tests/components/sensor/test_darksky.py b/tests/components/sensor/test_darksky.py index ccfe4344373..33a13f013de 100644 --- a/tests/components/sensor/test_darksky.py +++ b/tests/components/sensor/test_darksky.py @@ -20,7 +20,7 @@ VALID_CONFIG_MINIMAL = { 'platform': 'darksky', 'api_key': 'foo', 'forecast': [1, 2], - 'monitored_conditions': ['summary', 'icon', 'temperature_max'], + 'monitored_conditions': ['summary', 'icon', 'temperature_high'], 'update_interval': timedelta(seconds=120), } } @@ -30,7 +30,7 @@ INVALID_CONFIG_MINIMAL = { 'platform': 'darksky', 'api_key': 'foo', 'forecast': [1, 2], - 'monitored_conditions': ['sumary', 'iocn', 'temperature_max'], + 'monitored_conditions': ['sumary', 'iocn', 'temperature_high'], 'update_interval': timedelta(seconds=120), } } @@ -42,7 +42,7 @@ VALID_CONFIG_LANG_DE = { 'forecast': [1, 2], 'units': 'us', 'language': 'de', - 'monitored_conditions': ['summary', 'icon', 'temperature_max', + 'monitored_conditions': ['summary', 'icon', 'temperature_high', 'minutely_summary', 'hourly_summary', 'daily_summary', 'humidity', ], 'update_interval': timedelta(seconds=120), @@ -55,7 +55,7 @@ INVALID_CONFIG_LANG = { 'api_key': 'foo', 'forecast': [1, 2], 'language': 'yz', - 'monitored_conditions': ['summary', 'icon', 'temperature_max'], + 'monitored_conditions': ['summary', 'icon', 'temperature_high'], 'update_interval': timedelta(seconds=120), } } @@ -154,7 +154,7 @@ class TestDarkSkySetup(unittest.TestCase): assert mock_get_forecast.called assert mock_get_forecast.call_count == 1 - assert len(self.hass.states.entity_ids()) == 9 + assert len(self.hass.states.entity_ids()) == 8 state = self.hass.states.get('sensor.dark_sky_summary') assert state is not None From de9bac9ee39e154e78d2bb0569351151ec721fc9 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Mon, 19 Nov 2018 16:49:04 +0100 Subject: [PATCH 196/238] Reconfigure MQTT binary_sensor component if discovery info is changed (#18169) * Recreate component if discovery info is changed * Update component instead of remove+add * Set name and unique_id in __init__ * Update unit test * Cleanup * More cleanup * Refactor according to review comments * Change discovery_hash * Review comments, add tests * Fix handling of value_template --- .../components/binary_sensor/mqtt.py | 109 +++++++---- homeassistant/components/mqtt/__init__.py | 40 +++- homeassistant/components/mqtt/discovery.py | 43 +++-- homeassistant/components/mqtt/subscription.py | 54 ++++++ tests/common.py | 1 + tests/components/binary_sensor/test_mqtt.py | 68 ++++++- tests/components/mqtt/test_discovery.py | 2 +- tests/components/mqtt/test_subscription.py | 180 ++++++++++++++++++ 8 files changed, 431 insertions(+), 66 deletions(-) create mode 100644 homeassistant/components/mqtt/subscription.py create mode 100644 tests/components/mqtt/test_subscription.py diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 7f164ae48d7..f7bd353f3d1 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -19,7 +18,8 @@ from homeassistant.const import ( from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo) + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -79,21 +79,8 @@ async def _async_setup_entity(hass, config, async_add_entities, value_template.hass = hass async_add_entities([MqttBinarySensor( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_DEVICE_CLASS), - config.get(CONF_QOS), - config.get(CONF_FORCE_UPDATE), - config.get(CONF_OFF_DELAY), - config.get(CONF_PAYLOAD_ON), - config.get(CONF_PAYLOAD_OFF), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - value_template, - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE), - discovery_hash, + config, + discovery_hash )]) @@ -101,35 +88,71 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, name, state_topic, availability_topic, device_class, - qos, force_update, off_delay, payload_on, payload_off, - payload_available, payload_not_available, value_template, - unique_id: Optional[str], device_config: Optional[ConfigType], - discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the MQTT binary sensor.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - MqttEntityDeviceInfo.__init__(self, device_config) - self._name = name + self._config = config self._state = None - self._state_topic = state_topic - self._device_class = device_class - self._payload_on = payload_on - self._payload_off = payload_off - self._qos = qos - self._force_update = force_update - self._off_delay = off_delay - self._template = value_template - self._unique_id = unique_id - self._discovery_hash = discovery_hash + self._sub_state = None self._delay_listener = None + self._name = None + self._state_topic = None + self._device_class = None + self._payload_on = None + self._payload_off = None + self._qos = None + self._force_update = None + self._off_delay = None + self._template = None + self._unique_id = None + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config) + async def async_added_to_hass(self): """Subscribe mqtt events.""" await MqttAvailability.async_added_to_hass(self) await MqttDiscoveryUpdate.async_added_to_hass(self) + await self._subscribe_topics() + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._state_topic = config.get(CONF_STATE_TOPIC) + self._device_class = config.get(CONF_DEVICE_CLASS) + self._qos = config.get(CONF_QOS) + self._force_update = config.get(CONF_FORCE_UPDATE) + self._off_delay = config.get(CONF_OFF_DELAY) + self._payload_on = config.get(CONF_PAYLOAD_ON) + self._payload_off = config.get(CONF_PAYLOAD_OFF) + value_template = config.get(CONF_VALUE_TEMPLATE) + if value_template is not None and value_template.hass is None: + value_template.hass = self.hass + self._template = value_template + + self._unique_id = config.get(CONF_UNIQUE_ID) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" @callback def off_delay_listener(now): """Switch device off after a delay.""" @@ -163,8 +186,16 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, self.async_schedule_update_ha_state() - await mqtt.async_subscribe( - self.hass, self._state_topic, state_message_received, self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._state_topic, + 'msg_callback': state_message_received, + 'qos': self._qos}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e06e025c7ab..66b10532664 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -832,12 +832,30 @@ class MqttAvailability(Entity): self._available = availability_topic is None # type: bool self._payload_available = payload_available self._payload_not_available = payload_not_available + self._availability_sub_state = None async def async_added_to_hass(self) -> None: """Subscribe MQTT events. This method must be run in the event loop and returns a coroutine. """ + await self._availability_subscribe_topics() + + async def availability_discovery_update(self, config: dict): + """Handle updated discovery message.""" + self._availability_setup_from_config(config) + await self._availability_subscribe_topics() + + def _availability_setup_from_config(self, config): + """(Re)Setup.""" + self._availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + self._payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + self._payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + + async def _availability_subscribe_topics(self): + """(Re)Subscribe to topics.""" + from .subscription import async_subscribe_topics + @callback def availability_message_received(topic: str, payload: SubscribePayloadType, @@ -850,10 +868,17 @@ class MqttAvailability(Entity): self.async_schedule_update_ha_state() - if self._availability_topic is not None: - await async_subscribe( - self.hass, self._availability_topic, - availability_message_received, self._availability_qos) + self._availability_sub_state = await async_subscribe_topics( + self.hass, self._availability_sub_state, + {'availability_topic': { + 'topic': self._availability_topic, + 'msg_callback': availability_message_received, + 'qos': self._availability_qos}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + from .subscription import async_unsubscribe_topics + await async_unsubscribe_topics(self.hass, self._availability_sub_state) @property def available(self) -> bool: @@ -864,9 +889,10 @@ class MqttAvailability(Entity): class MqttDiscoveryUpdate(Entity): """Mixin used to handle updated discovery message.""" - def __init__(self, discovery_hash) -> None: + def __init__(self, discovery_hash, discovery_update=None) -> None: """Initialize the discovery update mixin.""" self._discovery_hash = discovery_hash + self._discovery_update = discovery_update self._remove_signal = None async def async_added_to_hass(self) -> None: @@ -886,6 +912,10 @@ class MqttDiscoveryUpdate(Entity): self.hass.async_create_task(self.async_remove()) del self.hass.data[ALREADY_DISCOVERED][self._discovery_hash] self._remove_signal() + elif self._discovery_update: + # Non-empty payload: Notify component + _LOGGER.info("Updating component: %s", self.entity_id) + self.hass.async_create_task(self._discovery_update(payload)) if self._discovery_hash: self._remove_signal = async_dispatcher_connect( diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index f680cd9c136..d91ab6ee445 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -208,15 +208,32 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, if value[-1] == TOPIC_BASE and key.endswith('_topic'): payload[key] = "{}{}".format(value[:-1], base) - # If present, the node_id will be included in the discovered object id - discovery_id = '_'.join((node_id, object_id)) if node_id else object_id + # If present, unique_id is used as the discovered object id. Otherwise, + # if present, the node_id will be included in the discovered object id + discovery_id = payload.get( + 'unique_id', ' '.join( + (node_id, object_id)) if node_id else object_id) + discovery_hash = (component, discovery_id) + + if payload: + platform = payload.get(CONF_PLATFORM, 'mqtt') + if platform not in ALLOWED_PLATFORMS.get(component, []): + _LOGGER.warning("Platform %s (component %s) is not allowed", + platform, component) + return + payload[CONF_PLATFORM] = platform + + if CONF_STATE_TOPIC not in payload: + payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( + discovery_topic, component, + '%s/' % node_id if node_id else '', object_id) + + payload[ATTR_DISCOVERY_HASH] = discovery_hash if ALREADY_DISCOVERED not in hass.data: hass.data[ALREADY_DISCOVERED] = {} - - discovery_hash = (component, discovery_id) - if discovery_hash in hass.data[ALREADY_DISCOVERED]: + # Dispatch update _LOGGER.info( "Component has already been discovered: %s %s, sending update", component, discovery_id) @@ -224,22 +241,8 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload) elif payload: # Add component - platform = payload.get(CONF_PLATFORM, 'mqtt') - if platform not in ALLOWED_PLATFORMS.get(component, []): - _LOGGER.warning("Platform %s (component %s) is not allowed", - platform, component) - return - - payload[CONF_PLATFORM] = platform - if CONF_STATE_TOPIC not in payload: - payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( - discovery_topic, component, - '%s/' % node_id if node_id else '', object_id) - - hass.data[ALREADY_DISCOVERED][discovery_hash] = None - payload[ATTR_DISCOVERY_HASH] = discovery_hash - _LOGGER.info("Found new component: %s %s", component, discovery_id) + hass.data[ALREADY_DISCOVERED][discovery_hash] = None if platform not in CONFIG_ENTRY_PLATFORMS.get(component, []): await async_load_platform( diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py new file mode 100644 index 00000000000..8be8d311d9b --- /dev/null +++ b/homeassistant/components/mqtt/subscription.py @@ -0,0 +1,54 @@ +""" +Helper to handle a set of topics to subscribe to. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt/ +""" +import logging + +from homeassistant.components import mqtt +from homeassistant.components.mqtt import DEFAULT_QOS +from homeassistant.loader import bind_hass +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + + +@bind_hass +async def async_subscribe_topics(hass: HomeAssistantType, sub_state: dict, + topics: dict): + """(Re)Subscribe to a set of MQTT topics. + + State is kept in sub_state. + """ + cur_state = sub_state if sub_state is not None else {} + sub_state = {} + for key in topics: + topic = topics[key].get('topic', None) + msg_callback = topics[key].get('msg_callback', None) + qos = topics[key].get('qos', DEFAULT_QOS) + encoding = topics[key].get('encoding', 'utf-8') + topic = (topic, msg_callback, qos, encoding) + (cur_topic, unsub) = cur_state.pop( + key, ((None, None, None, None), None)) + + if topic != cur_topic and topic[0] is not None: + if unsub is not None: + unsub() + unsub = await mqtt.async_subscribe( + hass, topic[0], topic[1], topic[2], topic[3]) + sub_state[key] = (topic, unsub) + + for key, (topic, unsub) in list(cur_state.items()): + if unsub is not None: + unsub() + + return sub_state + + +@bind_hass +async def async_unsubscribe_topics(hass: HomeAssistantType, sub_state: dict): + """Unsubscribe from all MQTT topics managed by async_subscribe_topics.""" + await async_subscribe_topics(hass, sub_state, {}) + + return sub_state diff --git a/tests/common.py b/tests/common.py index b3d72cbebbf..c6a75fcb63d 100644 --- a/tests/common.py +++ b/tests/common.py @@ -295,6 +295,7 @@ def async_mock_mqtt_component(hass, config=None): with patch('paho.mqtt.client.Client') as mock_client: mock_client().connect.return_value = 0 mock_client().subscribe.return_value = (0, 0) + mock_client().unsubscribe.return_value = (0, 0) mock_client().publish.return_value = (0, 0) result = yield from async_setup_component(hass, mqtt.DOMAIN, { diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index d49bbb329e4..88bd39ebfe2 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -284,7 +284,8 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): await async_start(hass, 'homeassistant', {}, entry) data = ( '{ "name": "Beer",' - ' "status_topic": "test_topic" }' + ' "state_topic": "test_topic",' + ' "availability_topic": "availability_topic" }' ) async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', data) @@ -300,6 +301,71 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): + """Test removal of discovered binary_sensor.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "availability_topic": "availability_topic1" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic2",' + ' "availability_topic": "availability_topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data1) + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert state is not None + assert state.name == 'Beer' + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('binary_sensor.milk') + assert state is None + + +async def test_discovery_unique_id(hass, mqtt_mock, caplog): + """Test unique id option only creates one sensor per unique_id.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "unique_id": "TOTALLY_UNIQUE" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic",' + ' "unique_id": "TOTALLY_DIFFERENT" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data1) + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert state is not None + assert state.name == 'Beer' + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert state is not None + assert state.name == 'Beer' + + state = hass.states.get('binary_sensor.milk') + assert state is not None + assert state.name == 'Milk' + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT binary sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index b075b8db063..083227e27c0 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -185,7 +185,7 @@ def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): assert state is not None assert state.name == 'Beer' - assert ('binary_sensor', 'my_node_id_bla') in hass.data[ALREADY_DISCOVERED] + assert ('binary_sensor', 'my_node_id bla') in hass.data[ALREADY_DISCOVERED] @asyncio.coroutine diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py new file mode 100644 index 00000000000..102b71d7b53 --- /dev/null +++ b/tests/components/mqtt/test_subscription.py @@ -0,0 +1,180 @@ +"""The tests for the MQTT subscription component.""" +from homeassistant.core import callback +from homeassistant.components.mqtt.subscription import ( + async_subscribe_topics, async_unsubscribe_topics) + +from tests.common import async_fire_mqtt_message, async_mock_mqtt_component + + +async def test_subscribe_topics(hass, mqtt_mock, caplog): + """Test subscription to topics.""" + calls1 = [] + + @callback + def record_calls1(*args): + """Record calls.""" + calls1.append(args) + + calls2 = [] + + @callback + def record_calls2(*args): + """Record calls.""" + calls2.append(args) + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': record_calls1}, + 'test_topic2': {'topic': 'test-topic2', + 'msg_callback': record_calls2}}) + + async_fire_mqtt_message(hass, 'test-topic1', 'test-payload1') + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 'test-topic1' == calls1[0][0] + assert 'test-payload1' == calls1[0][1] + assert 0 == len(calls2) + + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload2') + await hass.async_block_till_done() + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 1 == len(calls2) + assert 'test-topic2' == calls2[0][0] + assert 'test-payload2' == calls2[0][1] + + await async_unsubscribe_topics(hass, sub_state) + + async_fire_mqtt_message(hass, 'test-topic1', 'test-payload') + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload') + + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 1 == len(calls2) + + +async def test_modify_topics(hass, mqtt_mock, caplog): + """Test modification of topics.""" + calls1 = [] + + @callback + def record_calls1(*args): + """Record calls.""" + calls1.append(args) + + calls2 = [] + + @callback + def record_calls2(*args): + """Record calls.""" + calls2.append(args) + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': record_calls1}, + 'test_topic2': {'topic': 'test-topic2', + 'msg_callback': record_calls2}}) + + async_fire_mqtt_message(hass, 'test-topic1', 'test-payload') + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 0 == len(calls2) + + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload') + await hass.async_block_till_done() + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 1 == len(calls2) + + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1_1', + 'msg_callback': record_calls1}}) + + async_fire_mqtt_message(hass, 'test-topic1', 'test-payload') + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload') + await hass.async_block_till_done() + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 1 == len(calls2) + + async_fire_mqtt_message(hass, 'test-topic1_1', 'test-payload') + await hass.async_block_till_done() + await hass.async_block_till_done() + assert 2 == len(calls1) + assert 'test-topic1_1' == calls1[1][0] + assert 'test-payload' == calls1[1][1] + assert 1 == len(calls2) + + await async_unsubscribe_topics(hass, sub_state) + + async_fire_mqtt_message(hass, 'test-topic1_1', 'test-payload') + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload') + + await hass.async_block_till_done() + assert 2 == len(calls1) + assert 1 == len(calls2) + + +async def test_qos_encoding_default(hass, mqtt_mock, caplog): + """Test default qos and encoding.""" + mock_mqtt = await async_mock_mqtt_component(hass) + + @callback + def msg_callback(*args): + """Do nothing.""" + pass + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': msg_callback}}) + mock_mqtt.async_subscribe.assert_called_once_with( + 'test-topic1', msg_callback, 0, 'utf-8') + + +async def test_qos_encoding_custom(hass, mqtt_mock, caplog): + """Test custom qos and encoding.""" + mock_mqtt = await async_mock_mqtt_component(hass) + + @callback + def msg_callback(*args): + """Do nothing.""" + pass + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': msg_callback, + 'qos': 1, + 'encoding': 'utf-16'}}) + mock_mqtt.async_subscribe.assert_called_once_with( + 'test-topic1', msg_callback, 1, 'utf-16') + + +async def test_no_change(hass, mqtt_mock, caplog): + """Test subscription to topics without change.""" + mock_mqtt = await async_mock_mqtt_component(hass) + + @callback + def msg_callback(*args): + """Do nothing.""" + pass + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': msg_callback}}) + call_count = mock_mqtt.async_subscribe.call_count + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': msg_callback}}) + assert call_count == mock_mqtt.async_subscribe.call_count From 3891f2eebe9fc007d566b42e17120eee94b5f8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=C3=B3s=20P=C3=A9ter?= Date: Mon, 19 Nov 2018 16:54:09 +0100 Subject: [PATCH 197/238] Add mikrotik SSL support (#17898) * Update mikrotik.py * Update mikrotik.py * Added basic api_ssl support Added preliminary support to use api_ssl instead of api. It don't check the validity of the certificate need it. At Home Assistant side add ssl = true to your sensor configuration, and don't forget to change the port too (to 8729 by default): device_tracker: - platform: mikrotik host: 192.168.88.1 port: 8729 ssl: true username: homeassistant password: TopSecret At MikroTik side you have to add or generate a certificate, and configure api_ssl to use it. Here is an example: /certificate add common-name="Self signed demo certificate for API" days-valid=3650 name="Self signed demo certificate for API" key-usage=digital-signature,key-encipherment,tls-server,key-cert-sign,crl-sign /certificate sign "Self signed demo certificate for API" /ip service set api-ssl certificate="Self signed demo certificate for API" /ip service enable api-ssl /ip service disable api /user group add name=homeassistant policy=read,api,!local,!telnet,!ssh,!ftp,!reboot,!write,!policy,!test,!winbox,!password,!web,!sniff,!sensitive,!romon,!dude,!tikapp /user add group=homeassistant name=homeassistant /user set password="TopSecret" homeassistant * Fixed import missind ssl lib * SSL support code cleanup, use ssl-api port by default if ssl enabled * Restored accidentalli deleted method parameter * Fixed Python 3.5.3 compilation errors Fixed Python 3.5.3 compilation errors reported by Travis CI * Removed duplicated MTK_DEFAULT_API_PORT --- .../components/device_tracker/mikrotik.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index d6b965cbadf..5b69c13afa6 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -6,26 +6,30 @@ https://home-assistant.io/components/device_tracker.mikrotik/ """ import logging +import ssl + 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, CONF_PORT, CONF_METHOD) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_METHOD) REQUIREMENTS = ['librouteros==2.1.1'] _LOGGER = logging.getLogger(__name__) MTK_DEFAULT_API_PORT = '8728' +MTK_DEFAULT_API_SSL_PORT = '8729' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_METHOD): cv.string, - vol.Optional(CONF_PORT, default=MTK_DEFAULT_API_PORT): cv.port, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean }) @@ -43,7 +47,14 @@ class MikrotikScanner(DeviceScanner): self.last_results = {} self.host = config[CONF_HOST] - self.port = config[CONF_PORT] + self.ssl = config[CONF_SSL] + try: + self.port = config[CONF_PORT] + except KeyError: + if self.ssl: + self.port = MTK_DEFAULT_API_SSL_PORT + else: + self.port = MTK_DEFAULT_API_PORT self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] self.method = config.get(CONF_METHOD) @@ -64,9 +75,21 @@ class MikrotikScanner(DeviceScanner): """Connect to Mikrotik method.""" import librouteros try: + kwargs = { + 'port': self.port, + 'encoding': 'utf-8' + } + if self.ssl: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + kwargs['ssl_wrapper'] = ssl_context.wrap_socket self.client = librouteros.connect( - self.host, self.username, self.password, port=int(self.port), - encoding='utf-8') + self.host, + self.username, + self.password, + **kwargs + ) try: routerboard_info = self.client( From f86083cf52d7bbb45df290bed2d4bef436a877d0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 19 Nov 2018 20:48:26 +0100 Subject: [PATCH 198/238] Fix MQTT async_added_to_hass (#18575) * Fix some invocations * Update manual_mqtt.py --- .../components/alarm_control_panel/manual_mqtt.py | 9 +++------ homeassistant/components/camera/mqtt.py | 5 ++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index 834a502baa0..fc59ac4d088 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -335,11 +335,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): return state_attr - def async_added_to_hass(self): - """Subscribe to MQTT events. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" async_track_state_change( self.hass, self.entity_id, self._async_state_changed_listener ) @@ -359,7 +356,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): _LOGGER.warning("Received unexpected payload: %s", payload) return - return mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._command_topic, message_received, self._qos) async def _async_state_changed_listener(self, entity_id, old_state, diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py index 42ad7d6fa66..7bda891e921 100644 --- a/homeassistant/components/camera/mqtt.py +++ b/homeassistant/components/camera/mqtt.py @@ -89,13 +89,12 @@ class MqttCamera(Camera): """Return a unique ID.""" return self._unique_id - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe MQTT events.""" @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" self._last_image = payload - return mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic, message_received, self._qos, None) From 14ad7428eaf82b16fcac1905906ec57183690aef Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Mon, 19 Nov 2018 20:55:08 +0100 Subject: [PATCH 199/238] Prefix all xiaomi_aqara events (#17354) --- homeassistant/components/binary_sensor/xiaomi_aqara.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 45217c42c1d..550bdaac172 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -209,7 +209,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor): else: self._should_poll = True if self.entity_id is not None: - self._hass.bus.fire('motion', { + self._hass.bus.fire('xiaomi_aqara.motion', { 'entity_id': self.entity_id }) @@ -417,7 +417,7 @@ class XiaomiButton(XiaomiBinarySensor): _LOGGER.warning("Unsupported click_type detected: %s", value) return False - self._hass.bus.fire('click', { + self._hass.bus.fire('xiaomi_aqara.click', { 'entity_id': self.entity_id, 'click_type': click_type }) @@ -453,14 +453,14 @@ class XiaomiCube(XiaomiBinarySensor): def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if self._data_key in data: - self._hass.bus.fire('cube_action', { + self._hass.bus.fire('xiaomi_aqara.cube_action', { 'entity_id': self.entity_id, 'action_type': data[self._data_key] }) self._last_action = data[self._data_key] if 'rotate' in data: - self._hass.bus.fire('cube_action', { + self._hass.bus.fire('xiaomi_aqara.cube_action', { 'entity_id': self.entity_id, 'action_type': 'rotate', 'action_value': float(data['rotate'].replace(",", ".")) From 57f7e7eedc20d07dbf3186e7bb2876d9b6a84b13 Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Mon, 19 Nov 2018 21:01:26 +0100 Subject: [PATCH 200/238] Bumped ghlocalapi to 0.1.0 (#18584) * mumped ghlocalapi to 0.1.0 * update requirement_all.txt --- homeassistant/components/device_tracker/googlehome.py | 6 +++--- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py index 4ab76905478..575d9688493 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['ghlocalapi==0.0.1'] +REQUIREMENTS = ['ghlocalapi==0.1.0'] _LOGGER = logging.getLogger(__name__) @@ -40,7 +40,7 @@ class GoogleHomeDeviceScanner(DeviceScanner): def __init__(self, hass, config): """Initialize the scanner.""" from ghlocalapi.device_info import DeviceInfo - from ghlocalapi.bluetooth import BluetoothScan + from ghlocalapi.bluetooth import Bluetooth self.last_results = {} @@ -50,7 +50,7 @@ class GoogleHomeDeviceScanner(DeviceScanner): session = async_get_clientsession(hass) self.deviceinfo = DeviceInfo(hass.loop, session, self._host) - self.scanner = BluetoothScan(hass.loop, session, self._host) + self.scanner = Bluetooth(hass.loop, session, self._host) async def async_connect(self): """Initialize connection to Google Home.""" diff --git a/requirements_all.txt b/requirements_all.txt index 9e129753dbb..baa41f0b7dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ geojson_client==0.3 georss_client==0.4 # homeassistant.components.device_tracker.googlehome -ghlocalapi==0.0.1 +ghlocalapi==0.1.0 # homeassistant.components.sensor.gitter gitterpy==0.1.7 From b3e37af9b1753c902a7722458b88e4f241740dd7 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Mon, 19 Nov 2018 22:52:43 -0500 Subject: [PATCH 201/238] Added unique id to all Wink devices. (#18589) --- homeassistant/components/wink/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 3db044c4d1b..a94f8c3bdf2 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -687,6 +687,11 @@ class WinkDevice(Entity): """Return the name of the device.""" return self.wink.name() + @property + def unique_id(self): + """Return the unique id of the Wink device.""" + return self.wink.object_id() + @property def available(self): """Return true if connection == True.""" From 80cd8b180cf5acad616313858e8ef0fc4b517edc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 20 Nov 2018 11:51:34 +0100 Subject: [PATCH 202/238] Adds light switch platform (#18562) * Adds light switch platform * Addresses HoundCI warnings * Addresses Flake8 reported issues * Removes async_update call as per review --- homeassistant/components/light/switch.py | 113 +++++++++++++++++++++++ tests/components/light/test_switch.py | 81 ++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 homeassistant/components/light/switch.py create mode 100644 tests/components/light/test_switch.py diff --git a/homeassistant/components/light/switch.py b/homeassistant/components/light/switch.py new file mode 100644 index 00000000000..de6247a2772 --- /dev/null +++ b/homeassistant/components/light/switch.py @@ -0,0 +1,113 @@ +""" +Light support for switch entities. + +For more information about this platform, please refer to the documentation at +https://home-assistant.io/components/light.switch/ +""" +import logging +import voluptuous as vol + +from homeassistant.core import State, callback +from homeassistant.components.light import ( + Light, PLATFORM_SCHEMA) +from homeassistant.components import switch +from homeassistant.const import ( + STATE_ON, + ATTR_ENTITY_ID, + CONF_NAME, + CONF_ENTITY_ID, + STATE_UNAVAILABLE +) +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.event import async_track_state_change +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Light Switch' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(switch.DOMAIN) +}) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_entities, + discovery_info=None) -> None: + """Initialize Light Switch platform.""" + async_add_entities([LightSwitch(config.get(CONF_NAME), + config[CONF_ENTITY_ID])], True) + + +class LightSwitch(Light): + """Represents a Switch as a Light.""" + + def __init__(self, name: str, switch_entity_id: str) -> None: + """Initialize Light Switch.""" + self._name = name # type: str + self._switch_entity_id = switch_entity_id # type: str + self._is_on = False # type: bool + self._available = False # type: bool + self._async_unsub_state_changed = None + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def is_on(self) -> bool: + """Return true if light switch is on.""" + return self._is_on + + @property + def available(self) -> bool: + """Return true if light switch is on.""" + return self._available + + @property + def should_poll(self) -> bool: + """No polling needed for a light switch.""" + return False + + async def async_turn_on(self, **kwargs): + """Forward the turn_on command to the switch in this light switch.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call( + switch.DOMAIN, switch.SERVICE_TURN_ON, data, blocking=True) + + async def async_turn_off(self, **kwargs): + """Forward the turn_off command to the switch in this light switch.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call( + switch.DOMAIN, switch.SERVICE_TURN_OFF, data, blocking=True) + + async def async_update(self): + """Query the switch in this light switch and determine the state.""" + switch_state = self.hass.states.get(self._switch_entity_id) + + if switch_state is None: + self._available = False + return + + self._is_on = switch_state.state == STATE_ON + self._available = switch_state.state != STATE_UNAVAILABLE + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + @callback + def async_state_changed_listener(entity_id: str, old_state: State, + new_state: State): + """Handle child updates.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_state_changed = async_track_state_change( + self.hass, self._switch_entity_id, async_state_changed_listener) + + async def async_will_remove_from_hass(self): + """Handle removal from Home Assistant.""" + if self._async_unsub_state_changed is not None: + self._async_unsub_state_changed() + self._async_unsub_state_changed = None + self._available = False diff --git a/tests/components/light/test_switch.py b/tests/components/light/test_switch.py new file mode 100644 index 00000000000..5e6bebb56ef --- /dev/null +++ b/tests/components/light/test_switch.py @@ -0,0 +1,81 @@ +"""The tests for the Light Switch platform.""" + +from homeassistant.setup import async_setup_component +from tests.components.light import common +from tests.components.switch import common as switch_common + + +async def test_default_state(hass): + """Test light switch default state.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'switch', 'entity_id': 'switch.test', + 'name': 'Christmas Tree Lights' + }}) + await hass.async_block_till_done() + + state = hass.states.get('light.christmas_tree_lights') + assert state is not None + assert state.state == 'unavailable' + assert state.attributes['supported_features'] == 0 + assert state.attributes.get('brightness') is None + assert state.attributes.get('hs_color') is None + assert state.attributes.get('color_temp') is None + assert state.attributes.get('white_value') is None + assert state.attributes.get('effect_list') is None + assert state.attributes.get('effect') is None + + +async def test_light_service_calls(hass): + """Test service calls to light.""" + await async_setup_component(hass, 'switch', {'switch': [ + {'platform': 'demo'} + ]}) + await async_setup_component(hass, 'light', {'light': [ + {'platform': 'switch', 'entity_id': 'switch.decorative_lights'} + ]}) + await hass.async_block_till_done() + + assert hass.states.get('light.light_switch').state == 'on' + + common.async_toggle(hass, 'light.light_switch') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'off' + assert hass.states.get('light.light_switch').state == 'off' + + common.async_turn_on(hass, 'light.light_switch') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'on' + assert hass.states.get('light.light_switch').state == 'on' + + common.async_turn_off(hass, 'light.light_switch') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'off' + assert hass.states.get('light.light_switch').state == 'off' + + +async def test_switch_service_calls(hass): + """Test service calls to switch.""" + await async_setup_component(hass, 'switch', {'switch': [ + {'platform': 'demo'} + ]}) + await async_setup_component(hass, 'light', {'light': [ + {'platform': 'switch', 'entity_id': 'switch.decorative_lights'} + ]}) + await hass.async_block_till_done() + + assert hass.states.get('light.light_switch').state == 'on' + + switch_common.async_turn_off(hass, 'switch.decorative_lights') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'off' + assert hass.states.get('light.light_switch').state == 'off' + + switch_common.async_turn_on(hass, 'switch.decorative_lights') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'on' + assert hass.states.get('light.light_switch').state == 'on' From 9b53b7e9e462f9c626590664d9537aaeb977540b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Nov 2018 12:18:46 +0100 Subject: [PATCH 203/238] Bump Python-Nest to 4.0.5 (#18580) * Fix Nest eco * Update Python-nest to 4.0.5 * Update requirements_all.txt * Update requirements_test_all.txt --- homeassistant/components/climate/nest.py | 12 +++--------- homeassistant/components/nest/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index bc63512fcf3..e580476e56a 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -168,18 +168,14 @@ class NestThermostat(ClimateDevice): @property def target_temperature(self): """Return the temperature we try to reach.""" - if self._mode != NEST_MODE_HEAT_COOL and \ - self._mode != STATE_ECO and \ - not self.is_away_mode_on: + if self._mode not in (NEST_MODE_HEAT_COOL, STATE_ECO): return self._target_temperature return None @property def target_temperature_low(self): """Return the lower bound temperature we try to reach.""" - if (self.is_away_mode_on or self._mode == STATE_ECO) and \ - self._eco_temperature[0]: - # eco_temperature is always a low, high tuple + if self._mode == STATE_ECO: return self._eco_temperature[0] if self._mode == NEST_MODE_HEAT_COOL: return self._target_temperature[0] @@ -188,9 +184,7 @@ class NestThermostat(ClimateDevice): @property def target_temperature_high(self): """Return the upper bound temperature we try to reach.""" - if (self.is_away_mode_on or self._mode == STATE_ECO) and \ - self._eco_temperature[1]: - # eco_temperature is always a low, high tuple + if self._mode == STATE_ECO: return self._eco_temperature[1] if self._mode == NEST_MODE_HEAT_COOL: return self._target_temperature[1] diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 5f584d20c55..5bbd36f4b9d 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from . import local_auth -REQUIREMENTS = ['python-nest==4.0.4'] +REQUIREMENTS = ['python-nest==4.0.5'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index baa41f0b7dd..d097c14a701 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1222,7 +1222,7 @@ python-mpd2==1.0.0 python-mystrom==0.4.4 # homeassistant.components.nest -python-nest==4.0.4 +python-nest==4.0.5 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ff8df5e811..4ed7510cae0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -196,7 +196,7 @@ pyspcwebgw==0.4.0 python-forecastio==1.4.0 # homeassistant.components.nest -python-nest==4.0.4 +python-nest==4.0.5 # homeassistant.components.sensor.whois pythonwhois==2.4.3 From 44b33d45b1a6abc23f353b072eb1a9a42a69f935 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Tue, 20 Nov 2018 05:44:17 -0600 Subject: [PATCH 204/238] Add websocket calls to shopping-list (#18392) * Add websocket calls to shopping-list Plan to deprecate API calls once shopping-list panel is removed from UI and replaced fully by Lovelace card * Address ci-bot issues * Fix violations * Address travis complaints * Address review comments * Update test_shopping_list.py --- homeassistant/components/shopping_list.py | 20 +++++++++++++ tests/components/test_shopping_list.py | 34 ++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index f113561429a..45650ece621 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -13,6 +13,7 @@ from homeassistant.components.http.data_validator import ( from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json +from homeassistant.components import websocket_api ATTR_NAME = 'name' @@ -36,6 +37,13 @@ SERVICE_ITEM_SCHEMA = vol.Schema({ vol.Required(ATTR_NAME): vol.Any(None, cv.string) }) +WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' + +SCHEMA_WEBSOCKET_ITEMS = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_ITEMS + }) + @asyncio.coroutine def async_setup(hass, config): @@ -91,6 +99,11 @@ def async_setup(hass, config): yield from hass.components.frontend.async_register_built_in_panel( 'shopping-list', 'shopping_list', 'mdi:cart') + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_ITEMS, + websocket_handle_items, + SCHEMA_WEBSOCKET_ITEMS) + return True @@ -256,3 +269,10 @@ class ClearCompletedItemsView(http.HomeAssistantView): hass.data[DOMAIN].async_clear_completed() hass.bus.async_fire(EVENT) return self.json_message('Cleared completed items.') + + +@callback +def websocket_handle_items(hass, connection, msg): + """Handle get shopping_list items.""" + connection.send_message(websocket_api.result_message( + msg['id'], hass.data[DOMAIN].items)) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 3131ae092a3..e64b9a5ae26 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -6,6 +6,7 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.helpers import intent +from homeassistant.components.websocket_api.const import TYPE_RESULT @pytest.fixture(autouse=True) @@ -54,7 +55,7 @@ def test_recent_items_intent(hass): @asyncio.coroutine -def test_api_get_all(hass, aiohttp_client): +def test_deprecated_api_get_all(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -77,6 +78,37 @@ def test_api_get_all(hass, aiohttp_client): assert not data[1]['complete'] +async def test_ws_get_items(hass, hass_ws_client): + """Test get shopping_list items websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + client = await hass_ws_client(hass) + + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items', + }) + msg = await client.receive_json() + assert msg['success'] is True + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + data = msg['result'] + assert len(data) == 2 + assert data[0]['name'] == 'beer' + assert not data[0]['complete'] + assert data[1]['name'] == 'wine' + assert not data[1]['complete'] + + @asyncio.coroutine def test_api_update(hass, aiohttp_client): """Test the API.""" From d88040eeed8b050ad57254d120d1e86f26057482 Mon Sep 17 00:00:00 2001 From: dapowers87 <37314042+dapowers87@users.noreply.github.com> Date: Tue, 20 Nov 2018 06:29:05 -0600 Subject: [PATCH 205/238] Revert changes that broke UI (#18495) * Revert changes that broke UI * Change from UNKNOWN to None * Remove STATE_UNKNOWN import --- .../components/media_player/samsungtv.py | 7 +++--- .../components/media_player/test_samsungtv.py | 22 +++++++++---------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 1d40683e51e..9def9875ab4 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -17,7 +17,8 @@ from homeassistant.components.media_player import ( SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF) + CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF, + STATE_ON) import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as dt_util @@ -153,11 +154,11 @@ class SamsungTVDevice(MediaPlayerDevice): BrokenPipeError): # BrokenPipe can occur when the commands is sent to fast self._remote = None - self._state = None + self._state = STATE_ON except (self._exceptions_class.UnhandledResponse, self._exceptions_class.AccessDenied): # We got a response so it's on. - self._state = None + self._state = STATE_ON self._remote = None _LOGGER.debug("Failed sending command %s", key, exc_info=True) return diff --git a/tests/components/media_player/test_samsungtv.py b/tests/components/media_player/test_samsungtv.py index 4049ba66a3c..c2f5d28fd5d 100644 --- a/tests/components/media_player/test_samsungtv.py +++ b/tests/components/media_player/test_samsungtv.py @@ -12,8 +12,8 @@ from homeassistant.components.media_player import SUPPORT_TURN_ON, \ MEDIA_TYPE_CHANNEL, MEDIA_TYPE_URL from homeassistant.components.media_player.samsungtv import setup_platform, \ CONF_TIMEOUT, SamsungTVDevice, SUPPORT_SAMSUNGTV -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_MAC, \ - STATE_OFF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_ON, \ + CONF_MAC, STATE_OFF from tests.common import MockDependency from homeassistant.util import dt as dt_util from datetime import timedelta @@ -103,7 +103,7 @@ class TestSamsungTv(unittest.TestCase): def test_update_on(self): """Testing update tv on.""" self.device.update() - assert self.device._state is None + self.assertEqual(STATE_ON, self.device._state) def test_update_off(self): """Testing update tv off.""" @@ -117,7 +117,7 @@ class TestSamsungTv(unittest.TestCase): def test_send_key(self): """Test for send key.""" self.device.send_key('KEY_POWER') - assert self.device._state is None + self.assertEqual(STATE_ON, self.device._state) def test_send_key_broken_pipe(self): """Testing broken pipe Exception.""" @@ -126,8 +126,8 @@ class TestSamsungTv(unittest.TestCase): side_effect=BrokenPipeError('Boom')) self.device.get_remote = mock.Mock(return_value=_remote) self.device.send_key('HELLO') - assert self.device._remote is None - assert self.device._state is None + self.assertIsNone(self.device._remote) + self.assertEqual(STATE_ON, self.device._state) def test_send_key_connection_closed_retry_succeed(self): """Test retry on connection closed.""" @@ -138,7 +138,7 @@ class TestSamsungTv(unittest.TestCase): self.device.get_remote = mock.Mock(return_value=_remote) command = 'HELLO' self.device.send_key(command) - assert self.device._state is None + self.assertEqual(STATE_ON, self.device._state) # verify that _remote.control() get called twice because of retry logic expected = [mock.call(command), mock.call(command)] @@ -152,8 +152,8 @@ class TestSamsungTv(unittest.TestCase): ) self.device.get_remote = mock.Mock(return_value=_remote) self.device.send_key('HELLO') - assert self.device._remote is None - assert self.device._state is None + self.assertIsNone(self.device._remote) + self.assertEqual(STATE_ON, self.device._state) def test_send_key_os_error(self): """Testing broken pipe Exception.""" @@ -178,8 +178,8 @@ class TestSamsungTv(unittest.TestCase): def test_state(self): """Test for state property.""" - self.device._state = None - assert self.device.state is None + self.device._state = STATE_ON + self.assertEqual(STATE_ON, self.device.state) self.device._state = STATE_OFF assert STATE_OFF == self.device.state From 0ddd502d00ce154c8c16e7ca1c52f44ad768a0e3 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 20 Nov 2018 13:30:09 +0100 Subject: [PATCH 206/238] Hass.io config check (#18576) --- homeassistant/components/hassio/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 8523bb5ea64..4c13cb799a6 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -18,6 +18,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow +from homeassistant.exceptions import HomeAssistantError from .auth import async_setup_auth from .handler import HassIO, HassioAPIError @@ -143,6 +144,7 @@ async def async_check_config(hass): result = await hassio.check_homeassistant_config() except HassioAPIError as err: _LOGGER.error("Error on Hass.io API: %s", err) + raise HomeAssistantError() from None else: if result['result'] == "error": return result['message'] From 3838be4cb84cb4b7782af30150ff76faa970d7a4 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 20 Nov 2018 14:14:11 +0100 Subject: [PATCH 207/238] Add support for Daikin BRP069B41 (#18564) * Add support for BRP069B41, fixes #18563 * Use fan_mode and swing_mode from pydaikin 0.8 --- homeassistant/components/climate/daikin.py | 8 +++----- homeassistant/components/daikin.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 99e2d23de29..4a5c3258893 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pydaikin==0.7'] +REQUIREMENTS = ['pydaikin==0.8'] _LOGGER = logging.getLogger(__name__) @@ -101,12 +101,10 @@ class DaikinClimate(ClimateDevice): self._supported_features = SUPPORT_TARGET_TEMPERATURE \ | SUPPORT_OPERATION_MODE - daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE] - if self._api.device.values.get(daikin_attr) is not None: + if self._api.device.support_fan_mode: self._supported_features |= SUPPORT_FAN_MODE - daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE] - if self._api.device.values.get(daikin_attr) is not None: + if self._api.device.support_swing_mode: self._supported_features |= SUPPORT_SWING_MODE def get(self, key): diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py index 9ec5a8978f9..4fcd33bee26 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -19,7 +19,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = ['pydaikin==0.7'] +REQUIREMENTS = ['pydaikin==0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d097c14a701..d0753bb5af1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -886,7 +886,7 @@ pycsspeechtts==1.0.2 # homeassistant.components.daikin # homeassistant.components.climate.daikin -pydaikin==0.7 +pydaikin==0.8 # homeassistant.components.deconz pydeconz==47 From e87ecbd5007acad7468d7118d02b21f6d783c8bc Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 20 Nov 2018 08:59:34 -0500 Subject: [PATCH 208/238] Z-Wave Lock Config Entry Support (#18209) * Config Entry setup for zwave lock * fix merge conflict * lint * Update other tests * Fix tests * Remove debug line and unused import --- homeassistant/components/lock/zwave.py | 17 ++++++++++-- homeassistant/components/zwave/__init__.py | 2 +- tests/components/lock/test_zwave.py | 32 ++++++++++++++++++---- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 2ea8300fb9a..796c62377f1 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -8,8 +8,10 @@ import logging import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.lock import DOMAIN, LockDevice from homeassistant.components import zwave +from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -133,9 +135,18 @@ CLEAR_USERCODE_SCHEMA = vol.Schema({ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Z-Wave Lock platform.""" - await zwave.async_setup_platform( - hass, config, async_add_entities, discovery_info) + """Old method of setting up Z-Wave locks.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Lock from Config Entry.""" + @callback + def async_add_lock(lock): + """Add Z-Wave Lock.""" + async_add_entities([lock]) + + async_dispatcher_connect(hass, 'zwave_new_lock', async_add_lock) network = hass.data[zwave.const.DATA_NETWORK] diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 87a955f6f20..dd0b36020a4 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -67,7 +67,7 @@ DEFAULT_CONF_REFRESH_VALUE = False DEFAULT_CONF_REFRESH_DELAY = 5 SUPPORTED_PLATFORMS = ['binary_sensor', 'climate', 'cover', 'fan', - 'light', 'sensor', 'switch'] + 'lock', 'light', 'sensor', 'switch'] RENAME_NODE_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index 89ce034d445..e9ca5fb2b1f 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import patch, MagicMock +from homeassistant import config_entries from homeassistant.components.lock import zwave from homeassistant.components.zwave import const @@ -184,15 +185,27 @@ def test_lock_alarm_level(mock_openzwave): 'Tamper Alarm: Too many keypresses' +@asyncio.coroutine +def setup_ozw(hass, mock_openzwave): + """Set up the mock ZWave config entry.""" + hass.config.components.add('zwave') + config_entry = config_entries.ConfigEntry(1, 'zwave', 'Mock Title', { + 'usb_path': 'mock-path', + 'network_key': 'mock-key' + }, 'test', config_entries.CONN_CLASS_LOCAL_PUSH) + yield from hass.config_entries.async_forward_entry_setup(config_entry, + 'lock') + yield from hass.async_block_till_done() + + @asyncio.coroutine def test_lock_set_usercode_service(hass, mock_openzwave): """Test the zwave lock set_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() + node = MockNode(node_id=12) value0 = MockValue(data=' ', node=node, index=0) value1 = MockValue(data=' ', node=node, index=1) - yield from zwave.async_setup_platform( - hass, {}, MagicMock()) node.get_values.return_value = { value0.value_id: value0, @@ -202,6 +215,10 @@ def test_lock_set_usercode_service(hass, mock_openzwave): mock_network.nodes = { node.node_id: node } + + yield from setup_ozw(hass, mock_openzwave) + yield from hass.async_block_till_done() + yield from hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_SET_USERCODE, { const.ATTR_NODE_ID: node.node_id, @@ -233,14 +250,15 @@ def test_lock_get_usercode_service(hass, mock_openzwave): node = MockNode(node_id=12) value0 = MockValue(data=None, node=node, index=0) value1 = MockValue(data='1234', node=node, index=1) - yield from zwave.async_setup_platform( - hass, {}, MagicMock()) node.get_values.return_value = { value0.value_id: value0, value1.value_id: value1, } + yield from setup_ozw(hass, mock_openzwave) + yield from hass.async_block_till_done() + with patch.object(zwave, '_LOGGER') as mock_logger: mock_network.nodes = {node.node_id: node} yield from hass.services.async_call( @@ -262,8 +280,6 @@ def test_lock_clear_usercode_service(hass, mock_openzwave): node = MockNode(node_id=12) value0 = MockValue(data=None, node=node, index=0) value1 = MockValue(data='123', node=node, index=1) - yield from zwave.async_setup_platform( - hass, {}, MagicMock()) node.get_values.return_value = { value0.value_id: value0, @@ -273,6 +289,10 @@ def test_lock_clear_usercode_service(hass, mock_openzwave): mock_network.nodes = { node.node_id: node } + + yield from setup_ozw(hass, mock_openzwave) + yield from hass.async_block_till_done() + yield from hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_CLEAR_USERCODE, { const.ATTR_NODE_ID: node.node_id, From 874275092635d71033e96f02e5d0b08aa8ace89e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 20 Nov 2018 20:00:13 +0100 Subject: [PATCH 209/238] Improve available for Mill heater (#18597) * improve available for Mill heater * typo --- homeassistant/components/climate/mill.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index 3b26b456067..6be4fe183b7 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.7'] +REQUIREMENTS = ['millheater==0.2.8'] _LOGGER = logging.getLogger(__name__) @@ -98,7 +98,7 @@ class MillHeater(ClimateDevice): @property def available(self): """Return True if entity is available.""" - return self._heater.device_status == 0 # weird api choice + return self._heater.available @property def unique_id(self): diff --git a/requirements_all.txt b/requirements_all.txt index d0753bb5af1..13dd968804a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.7 +millheater==0.2.8 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 From b7742999cfa0e1a69657fa5a4758d13e77a728f7 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 20 Nov 2018 14:58:03 -0500 Subject: [PATCH 210/238] Update Z-Wave Tests asyncio/yield from -> async/await (#18599) * Update lock tests * Update binary sensor * Update zwave component tests --- tests/components/binary_sensor/test_zwave.py | 8 +- tests/components/lock/test_zwave.py | 48 +++---- tests/components/zwave/test_init.py | 143 ++++++++----------- tests/components/zwave/test_node_entity.py | 25 ++-- 4 files changed, 97 insertions(+), 127 deletions(-) diff --git a/tests/components/binary_sensor/test_zwave.py b/tests/components/binary_sensor/test_zwave.py index a5dabf6953a..f33e8a83e1e 100644 --- a/tests/components/binary_sensor/test_zwave.py +++ b/tests/components/binary_sensor/test_zwave.py @@ -1,5 +1,4 @@ """Test Z-Wave binary sensors.""" -import asyncio import datetime from unittest.mock import patch @@ -71,8 +70,7 @@ def test_binary_sensor_value_changed(mock_openzwave): assert device.is_on -@asyncio.coroutine -def test_trigger_sensor_value_changed(hass, mock_openzwave): +async def test_trigger_sensor_value_changed(hass, mock_openzwave): """Test value changed for trigger sensor.""" node = MockNode( manufacturer_id='013c', product_type='0002', product_id='0002') @@ -84,13 +82,13 @@ def test_trigger_sensor_value_changed(hass, mock_openzwave): assert not device.is_on value.data = True - yield from hass.async_add_job(value_changed, value) + await hass.async_add_job(value_changed, value) assert device.invalidate_after is None device.hass = hass value.data = True - yield from hass.async_add_job(value_changed, value) + await hass.async_add_job(value_changed, value) assert device.is_on test_time = device.invalidate_after - datetime.timedelta(seconds=1) diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index e9ca5fb2b1f..3955538273b 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -1,6 +1,4 @@ """Test Z-Wave locks.""" -import asyncio - from unittest.mock import patch, MagicMock from homeassistant import config_entries @@ -185,21 +183,19 @@ def test_lock_alarm_level(mock_openzwave): 'Tamper Alarm: Too many keypresses' -@asyncio.coroutine -def setup_ozw(hass, mock_openzwave): +async def setup_ozw(hass, mock_openzwave): """Set up the mock ZWave config entry.""" hass.config.components.add('zwave') config_entry = config_entries.ConfigEntry(1, 'zwave', 'Mock Title', { 'usb_path': 'mock-path', 'network_key': 'mock-key' }, 'test', config_entries.CONN_CLASS_LOCAL_PUSH) - yield from hass.config_entries.async_forward_entry_setup(config_entry, - 'lock') - yield from hass.async_block_till_done() + await hass.config_entries.async_forward_entry_setup(config_entry, + 'lock') + await hass.async_block_till_done() -@asyncio.coroutine -def test_lock_set_usercode_service(hass, mock_openzwave): +async def test_lock_set_usercode_service(hass, mock_openzwave): """Test the zwave lock set_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() @@ -216,35 +212,34 @@ def test_lock_set_usercode_service(hass, mock_openzwave): node.node_id: node } - yield from setup_ozw(hass, mock_openzwave) - yield from hass.async_block_till_done() + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_SET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_USERCODE: '1234', zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '1234' mock_network.nodes = { node.node_id: node } - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_SET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_USERCODE: '123', zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '1234' -@asyncio.coroutine -def test_lock_get_usercode_service(hass, mock_openzwave): +async def test_lock_get_usercode_service(hass, mock_openzwave): """Test the zwave lock get_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) @@ -256,25 +251,24 @@ def test_lock_get_usercode_service(hass, mock_openzwave): value1.value_id: value1, } - yield from setup_ozw(hass, mock_openzwave) - yield from hass.async_block_till_done() + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() with patch.object(zwave, '_LOGGER') as mock_logger: mock_network.nodes = {node.node_id: node} - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_GET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() # This service only seems to write to the log assert mock_logger.info.called assert len(mock_logger.info.mock_calls) == 1 assert mock_logger.info.mock_calls[0][1][2] == '1234' -@asyncio.coroutine -def test_lock_clear_usercode_service(hass, mock_openzwave): +async def test_lock_clear_usercode_service(hass, mock_openzwave): """Test the zwave lock clear_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) @@ -290,14 +284,14 @@ def test_lock_clear_usercode_service(hass, mock_openzwave): node.node_id: node } - yield from setup_ozw(hass, mock_openzwave) - yield from hass.async_block_till_done() + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_CLEAR_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_CODE_SLOT: 1 }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '\0\0\0' diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index c2634b2d621..d4077345649 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -23,36 +23,34 @@ from tests.common import ( from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues -@asyncio.coroutine -def test_valid_device_config(hass, mock_openzwave): +async def test_valid_device_config(hass, mock_openzwave): """Test valid device config.""" device_config = { 'light.kitchen': { 'ignored': 'true' } } - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'device_config': device_config }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert result -@asyncio.coroutine -def test_invalid_device_config(hass, mock_openzwave): +async def test_invalid_device_config(hass, mock_openzwave): """Test invalid device config.""" device_config = { 'light.kitchen': { 'some_ignored': 'true' } } - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'device_config': device_config }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not result @@ -69,15 +67,14 @@ def test_config_access_error(): assert result is None -@asyncio.coroutine -def test_network_options(hass, mock_openzwave): +async def test_network_options(hass, mock_openzwave): """Test network options.""" - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'usb_path': 'mock_usb_path', 'config_path': 'mock_config_path', }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert result @@ -86,62 +83,59 @@ def test_network_options(hass, mock_openzwave): assert network.options.config_path == 'mock_config_path' -@asyncio.coroutine -def test_auto_heal_midnight(hass, mock_openzwave): +async def test_auto_heal_midnight(hass, mock_openzwave): """Test network auto-heal at midnight.""" - yield from async_setup_component(hass, 'zwave', { + await async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': True, }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert network.heal.called assert len(network.heal.mock_calls) == 1 -@asyncio.coroutine -def test_auto_heal_disabled(hass, mock_openzwave): +async def test_auto_heal_disabled(hass, mock_openzwave): """Test network auto-heal disabled.""" - yield from async_setup_component(hass, 'zwave', { + await async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': False, }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not network.heal.called -@asyncio.coroutine -def test_setup_platform(hass, mock_openzwave): +async def test_setup_platform(hass, mock_openzwave): """Test invalid device config.""" mock_device = MagicMock() hass.data[DATA_NETWORK] = MagicMock() hass.data[zwave.DATA_DEVICES] = {456: mock_device} async_add_entities = MagicMock() - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, None) assert not result assert not async_add_entities.called - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, {const.DISCOVERY_DEVICE: 123}) assert not result assert not async_add_entities.called - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, {const.DISCOVERY_DEVICE: 456}) assert result assert async_add_entities.called @@ -149,12 +143,11 @@ def test_setup_platform(hass, mock_openzwave): assert async_add_entities.mock_calls[0][1][0] == [mock_device] -@asyncio.coroutine -def test_zwave_ready_wait(hass, mock_openzwave): +async def test_zwave_ready_wait(hass, mock_openzwave): """Test that zwave continues after waiting for network ready.""" # Initialize zwave - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() sleeps = [] @@ -163,18 +156,17 @@ def test_zwave_ready_wait(hass, mock_openzwave): asyncio_sleep = asyncio.sleep - @asyncio.coroutine - def sleep(duration, loop=None): + async def sleep(duration, loop=None): if duration > 0: sleeps.append(duration) - yield from asyncio_sleep(0) + await asyncio_sleep(0) with patch('homeassistant.components.zwave.dt_util.utcnow', new=utcnow): with patch('asyncio.sleep', new=sleep): with patch.object(zwave, '_LOGGER') as mock_logger: hass.data[DATA_NETWORK].state = MockNetwork.STATE_STARTED hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(sleeps) == const.NETWORK_READY_WAIT_SECS assert mock_logger.warning.called @@ -183,8 +175,7 @@ def test_zwave_ready_wait(hass, mock_openzwave): const.NETWORK_READY_WAIT_SECS -@asyncio.coroutine -def test_device_entity(hass, mock_openzwave): +async def test_device_entity(hass, mock_openzwave): """Test device entity base class.""" node = MockNode(node_id='10', name='Mock Node') value = MockValue(data=False, node=node, instance=2, object_id='11', @@ -197,7 +188,7 @@ def test_device_entity(hass, mock_openzwave): device.hass = hass device.value_added() device.update_properties() - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not device.should_poll assert device.unique_id == "10-11" @@ -205,8 +196,7 @@ def test_device_entity(hass, mock_openzwave): assert device.device_state_attributes[zwave.ATTR_POWER] == 50.123 -@asyncio.coroutine -def test_node_discovery(hass, mock_openzwave): +async def test_node_discovery(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -215,14 +205,14 @@ def test_node_discovery(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 node = MockNode(node_id=14) hass.async_add_job(mock_receivers[0], node) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('zwave.mock_node').state is 'unknown' @@ -270,8 +260,7 @@ async def test_unparsed_node_discovery(hass, mock_openzwave): assert hass.states.get('zwave.unknown_node_14').state is 'unknown' -@asyncio.coroutine -def test_node_ignored(hass, mock_openzwave): +async def test_node_ignored(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -280,24 +269,23 @@ def test_node_ignored(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': { + await async_setup_component(hass, 'zwave', {'zwave': { 'device_config': { 'zwave.mock_node': { 'ignored': True, }}}}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(mock_receivers) == 1 node = MockNode(node_id=14) hass.async_add_job(mock_receivers[0], node) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('zwave.mock_node') is None -@asyncio.coroutine -def test_value_discovery(hass, mock_openzwave): +async def test_value_discovery(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -306,8 +294,8 @@ def test_value_discovery(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -316,14 +304,13 @@ def test_value_discovery(hass, mock_openzwave): command_class=const.COMMAND_CLASS_SENSOR_BINARY, type=const.TYPE_BOOL, genre=const.GENRE_USER) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get( 'binary_sensor.mock_node_mock_value').state is 'off' -@asyncio.coroutine -def test_value_discovery_existing_entity(hass, mock_openzwave): +async def test_value_discovery_existing_entity(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -332,8 +319,8 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -343,7 +330,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): command_class=const.COMMAND_CLASS_THERMOSTAT_SETPOINT, genre=const.GENRE_USER, units='C') hass.async_add_job(mock_receivers[0], node, setpoint) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('climate.mock_node_mock_value').attributes[ 'temperature'] == 22.0 @@ -360,7 +347,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL, genre=const.GENRE_USER, units='C') hass.async_add_job(mock_receivers[0], node, temperature) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('climate.mock_node_mock_value').attributes[ 'temperature'] == 22.0 @@ -368,8 +355,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): 'current_temperature'] == 23.5 -@asyncio.coroutine -def test_power_schemes(hass, mock_openzwave): +async def test_power_schemes(hass, mock_openzwave): """Test power attribute.""" mock_receivers = [] @@ -378,8 +364,8 @@ def test_power_schemes(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -390,7 +376,7 @@ def test_power_schemes(hass, mock_openzwave): genre=const.GENRE_USER, type=const.TYPE_BOOL) hass.async_add_job(mock_receivers[0], node, switch) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('switch.mock_node_mock_value').state == 'on' assert 'power_consumption' not in hass.states.get( @@ -405,14 +391,13 @@ def test_power_schemes(hass, mock_openzwave): data=23.5, node=node, index=const.INDEX_SENSOR_MULTILEVEL_POWER, instance=13, command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL) hass.async_add_job(mock_receivers[0], node, power) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('switch.mock_node_mock_value').attributes[ 'power_consumption'] == 23.5 -@asyncio.coroutine -def test_network_ready(hass, mock_openzwave): +async def test_network_ready(hass, mock_openzwave): """Test Node network ready event.""" mock_receivers = [] @@ -421,8 +406,8 @@ def test_network_ready(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -434,13 +419,12 @@ def test_network_ready(hass, mock_openzwave): hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 -@asyncio.coroutine -def test_network_complete(hass, mock_openzwave): +async def test_network_complete(hass, mock_openzwave): """Test Node network complete event.""" mock_receivers = [] @@ -449,8 +433,8 @@ def test_network_complete(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -462,13 +446,12 @@ def test_network_complete(hass, mock_openzwave): hass.bus.async_listen(const.EVENT_NETWORK_READY, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 -@asyncio.coroutine -def test_network_complete_some_dead(hass, mock_openzwave): +async def test_network_complete_some_dead(hass, mock_openzwave): """Test Node network complete some dead event.""" mock_receivers = [] @@ -477,8 +460,8 @@ def test_network_complete_some_dead(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -490,7 +473,7 @@ def test_network_complete_some_dead(hass, mock_openzwave): hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE_SOME_DEAD, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 034360c6b3e..b8f88e6f37f 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -1,5 +1,4 @@ """Test Z-Wave node entity.""" -import asyncio import unittest from unittest.mock import patch, MagicMock import tests.mock.zwave as mock_zwave @@ -8,8 +7,7 @@ from homeassistant.components.zwave import node_entity, const from homeassistant.const import ATTR_ENTITY_ID -@asyncio.coroutine -def test_maybe_schedule_update(hass, mock_openzwave): +async def test_maybe_schedule_update(hass, mock_openzwave): """Test maybe schedule update.""" base_entity = node_entity.ZWaveBaseEntity() base_entity.hass = hass @@ -31,8 +29,7 @@ def test_maybe_schedule_update(hass, mock_openzwave): assert len(mock_call_later.mock_calls) == 2 -@asyncio.coroutine -def test_node_event_activated(hass, mock_openzwave): +async def test_node_event_activated(hass, mock_openzwave): """Test Node event activated event.""" mock_receivers = [] @@ -57,7 +54,7 @@ def test_node_event_activated(hass, mock_openzwave): # Test event before entity added to hass value = 234 hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -66,7 +63,7 @@ def test_node_event_activated(hass, mock_openzwave): value = 234 hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" @@ -74,8 +71,7 @@ def test_node_event_activated(hass, mock_openzwave): assert events[0].data[const.ATTR_BASIC_LEVEL] == value -@asyncio.coroutine -def test_scene_activated(hass, mock_openzwave): +async def test_scene_activated(hass, mock_openzwave): """Test scene activated event.""" mock_receivers = [] @@ -100,7 +96,7 @@ def test_scene_activated(hass, mock_openzwave): # Test event before entity added to hass scene_id = 123 hass.async_add_job(mock_receivers[0], node, scene_id) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -109,7 +105,7 @@ def test_scene_activated(hass, mock_openzwave): scene_id = 123 hass.async_add_job(mock_receivers[0], node, scene_id) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" @@ -117,8 +113,7 @@ def test_scene_activated(hass, mock_openzwave): assert events[0].data[const.ATTR_SCENE_ID] == scene_id -@asyncio.coroutine -def test_central_scene_activated(hass, mock_openzwave): +async def test_central_scene_activated(hass, mock_openzwave): """Test central scene activated event.""" mock_receivers = [] @@ -148,7 +143,7 @@ def test_central_scene_activated(hass, mock_openzwave): index=scene_id, data=scene_data) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -162,7 +157,7 @@ def test_central_scene_activated(hass, mock_openzwave): index=scene_id, data=scene_data) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" From d9c7f777c536936b1dfa2f21a6c6e63ef85db0a0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Nov 2018 23:23:07 +0100 Subject: [PATCH 211/238] Add cloud pref for Google unlock (#18600) --- homeassistant/components/cloud/__init__.py | 49 ++--------------- homeassistant/components/cloud/const.py | 4 ++ homeassistant/components/cloud/http_api.py | 14 ++--- homeassistant/components/cloud/iot.py | 4 +- homeassistant/components/cloud/prefs.py | 63 ++++++++++++++++++++++ tests/components/cloud/__init__.py | 8 +-- tests/components/cloud/test_http_api.py | 18 ++++--- tests/components/cloud/test_iot.py | 9 ++-- 8 files changed, 103 insertions(+), 66 deletions(-) create mode 100644 homeassistant/components/cloud/prefs.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index d9ee2a62b84..b968850668d 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -20,17 +20,12 @@ from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c -from . import http_api, iot, auth_api +from . import http_api, iot, auth_api, prefs from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 -STORAGE_ENABLE_ALEXA = 'alexa_enabled' -STORAGE_ENABLE_GOOGLE = 'google_enabled' _LOGGER = logging.getLogger(__name__) -_UNDEF = object() CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' @@ -70,8 +65,6 @@ ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}, - vol.Optional(ga_c.CONF_ALLOW_UNLOCK, - default=ga_c.DEFAULT_ALLOW_UNLOCK): cv.boolean }) CONFIG_SCHEMA = vol.Schema({ @@ -127,12 +120,11 @@ class Cloud: self.alexa_config = alexa self.google_actions_user_conf = google_actions self._gactions_config = None - self._prefs = None + self.prefs = prefs.CloudPreferences(hass) self.id_token = None self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -196,21 +188,11 @@ class Cloud: should_expose=should_expose, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), - allow_unlock=conf.get(ga_c.CONF_ALLOW_UNLOCK), + allow_unlock=self.prefs.google_allow_unlock, ) return self._gactions_config - @property - def alexa_enabled(self): - """Return if Alexa is enabled.""" - return self._prefs[STORAGE_ENABLE_ALEXA] - - @property - def google_enabled(self): - """Return if Google is enabled.""" - return self._prefs[STORAGE_ENABLE_GOOGLE] - def path(self, *parts): """Get config path inside cloud dir. @@ -250,20 +232,6 @@ class Cloud: async def async_start(self, _): """Start the cloud component.""" - prefs = await self._store.async_load() - if prefs is None: - prefs = {} - if self.mode not in prefs: - # Default to True if already logged in to make this not a - # breaking change. - enabled = await self.hass.async_add_executor_job( - os.path.isfile, self.user_info_path) - prefs = { - STORAGE_ENABLE_ALEXA: enabled, - STORAGE_ENABLE_GOOGLE: enabled, - } - self._prefs = prefs - def load_config(): """Load config.""" # Ensure config dir exists @@ -280,6 +248,8 @@ class Cloud: info = await self.hass.async_add_job(load_config) + await self.prefs.async_initialize(not info) + if info is None: return @@ -289,15 +259,6 @@ class Cloud: self.hass.add_job(self.iot.connect()) - async def update_preferences(self, *, google_enabled=_UNDEF, - alexa_enabled=_UNDEF): - """Update user preferences.""" - if google_enabled is not _UNDEF: - self._prefs[STORAGE_ENABLE_GOOGLE] = google_enabled - if alexa_enabled is not _UNDEF: - self._prefs[STORAGE_ENABLE_ALEXA] = alexa_enabled - await self._store.async_save(self._prefs) - def _decode_claims(self, token): # pylint: disable=no-self-use """Decode the claims in a token.""" from jose import jwt diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 88fb88474a1..abc72da796c 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -3,6 +3,10 @@ DOMAIN = 'cloud' CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 +PREF_ENABLE_ALEXA = 'alexa_enabled' +PREF_ENABLE_GOOGLE = 'google_enabled' +PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' + SERVERS = { 'production': { 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index cb62d773dfd..7b509f4eae2 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -15,7 +15,9 @@ from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import smart_home as google_sh from . import auth_api -from .const import DOMAIN, REQUEST_TIMEOUT +from .const import ( + DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_ALLOW_UNLOCK) from .iot import STATE_DISCONNECTED, STATE_CONNECTED _LOGGER = logging.getLogger(__name__) @@ -30,8 +32,9 @@ SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ WS_TYPE_UPDATE_PREFS = 'cloud/update_prefs' SCHEMA_WS_UPDATE_PREFS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_PREFS, - vol.Optional('google_enabled'): bool, - vol.Optional('alexa_enabled'): bool, + vol.Optional(PREF_ENABLE_GOOGLE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_GOOGLE_ALLOW_UNLOCK): bool, }) @@ -288,7 +291,7 @@ async def websocket_update_prefs(hass, connection, msg): changes = dict(msg) changes.pop('id') changes.pop('type') - await cloud.update_preferences(**changes) + await cloud.prefs.async_update(**changes) connection.send_message(websocket_api.result_message( msg['id'], {'success': True})) @@ -308,10 +311,9 @@ def _account_data(cloud): 'logged_in': True, 'email': claims['email'], 'cloud': cloud.iot.state, - 'google_enabled': cloud.google_enabled, + 'prefs': cloud.prefs.as_dict(), 'google_entities': cloud.google_actions_user_conf['filter'].config, 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), - 'alexa_enabled': cloud.alexa_enabled, 'alexa_entities': cloud.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index b4f228a630d..c5657ae9729 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -229,7 +229,7 @@ def async_handle_alexa(hass, cloud, payload): """Handle an incoming IoT message for Alexa.""" result = yield from alexa.async_handle_message( hass, cloud.alexa_config, payload, - enabled=cloud.alexa_enabled) + enabled=cloud.prefs.alexa_enabled) return result @@ -237,7 +237,7 @@ def async_handle_alexa(hass, cloud, payload): @asyncio.coroutine def async_handle_google_actions(hass, cloud, payload): """Handle an incoming IoT message for Google Actions.""" - if not cloud.google_enabled: + if not cloud.prefs.google_enabled: return ga.turned_off_response(payload) result = yield from ga.async_handle_message( diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py new file mode 100644 index 00000000000..d29b356cfc0 --- /dev/null +++ b/homeassistant/components/cloud/prefs.py @@ -0,0 +1,63 @@ +"""Preference management for cloud.""" +from .const import ( + DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_ALLOW_UNLOCK) + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +_UNDEF = object() + + +class CloudPreferences: + """Handle cloud preferences.""" + + def __init__(self, hass): + """Initialize cloud prefs.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._prefs = None + + async def async_initialize(self, logged_in): + """Finish initializing the preferences.""" + prefs = await self._store.async_load() + + if prefs is None: + # Backwards compat: we enable alexa/google if already logged in + prefs = { + PREF_ENABLE_ALEXA: logged_in, + PREF_ENABLE_GOOGLE: logged_in, + PREF_GOOGLE_ALLOW_UNLOCK: False, + } + + self._prefs = prefs + + async def async_update(self, *, google_enabled=_UNDEF, + alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF): + """Update user preferences.""" + for key, value in ( + (PREF_ENABLE_GOOGLE, google_enabled), + (PREF_ENABLE_ALEXA, alexa_enabled), + (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), + ): + if value is not _UNDEF: + self._prefs[key] = value + + await self._store.async_save(self._prefs) + + def as_dict(self): + """Return dictionary version.""" + return self._prefs + + @property + def alexa_enabled(self): + """Return if Alexa is enabled.""" + return self._prefs[PREF_ENABLE_ALEXA] + + @property + def google_enabled(self): + """Return if Google is enabled.""" + return self._prefs[PREF_ENABLE_GOOGLE] + + @property + def google_allow_unlock(self): + """Return if Google is allowed to unlock locks.""" + return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 108e5c45137..ba63e43d091 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -2,6 +2,7 @@ from unittest.mock import patch from homeassistant.setup import async_setup_component from homeassistant.components import cloud +from homeassistant.components.cloud import const from jose import jwt @@ -24,9 +25,10 @@ def mock_cloud(hass, config={}): def mock_cloud_prefs(hass, prefs={}): """Fixture for cloud component.""" prefs_to_set = { - cloud.STORAGE_ENABLE_ALEXA: True, - cloud.STORAGE_ENABLE_GOOGLE: True, + const.PREF_ENABLE_ALEXA: True, + const.PREF_ENABLE_GOOGLE: True, + const.PREF_GOOGLE_ALLOW_UNLOCK: True, } prefs_to_set.update(prefs) - hass.data[cloud.DOMAIN]._prefs = prefs_to_set + hass.data[cloud.DOMAIN].prefs._prefs = prefs_to_set return prefs_to_set diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index a8128c8d3e0..4abf5b8501d 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -6,7 +6,9 @@ import pytest from jose import jwt from homeassistant.components.cloud import ( - DOMAIN, auth_api, iot, STORAGE_ENABLE_GOOGLE, STORAGE_ENABLE_ALEXA) + DOMAIN, auth_api, iot) +from homeassistant.components.cloud.const import ( + PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK) from homeassistant.util import dt as dt_util from tests.common import mock_coro @@ -350,7 +352,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'logged_in': True, 'email': 'hello@home-assistant.io', 'cloud': 'connected', - 'alexa_enabled': True, + 'prefs': mock_cloud_fixture, 'alexa_entities': { 'include_domains': [], 'include_entities': ['light.kitchen', 'switch.ac'], @@ -358,7 +360,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'exclude_entities': [], }, 'alexa_domains': ['switch'], - 'google_enabled': True, 'google_entities': { 'include_domains': ['light'], 'include_entities': [], @@ -505,8 +506,9 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): async def test_websocket_update_preferences(hass, hass_ws_client, aioclient_mock, setup_api): """Test updating preference.""" - assert setup_api[STORAGE_ENABLE_GOOGLE] - assert setup_api[STORAGE_ENABLE_ALEXA] + assert setup_api[PREF_ENABLE_GOOGLE] + assert setup_api[PREF_ENABLE_ALEXA] + assert setup_api[PREF_GOOGLE_ALLOW_UNLOCK] hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', 'custom:sub-exp': '2018-01-03' @@ -517,9 +519,11 @@ async def test_websocket_update_preferences(hass, hass_ws_client, 'type': 'cloud/update_prefs', 'alexa_enabled': False, 'google_enabled': False, + 'google_allow_unlock': False, }) response = await client.receive_json() assert response['success'] - assert not setup_api[STORAGE_ENABLE_GOOGLE] - assert not setup_api[STORAGE_ENABLE_ALEXA] + assert not setup_api[PREF_ENABLE_GOOGLE] + assert not setup_api[PREF_ENABLE_ALEXA] + assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index d0b145c1b67..c900fc3a7a8 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -7,8 +7,9 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components.cloud import ( - Cloud, iot, auth_api, MODE_DEV, STORAGE_ENABLE_ALEXA, - STORAGE_ENABLE_GOOGLE) + Cloud, iot, auth_api, MODE_DEV) +from homeassistant.components.cloud.const import ( + PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) from tests.components.alexa import test_smart_home as test_alexa from tests.common import mock_coro @@ -308,7 +309,7 @@ def test_handler_alexa(hass): @asyncio.coroutine def test_handler_alexa_disabled(hass, mock_cloud_fixture): """Test handler Alexa when user has disabled it.""" - mock_cloud_fixture[STORAGE_ENABLE_ALEXA] = False + mock_cloud_fixture[PREF_ENABLE_ALEXA] = False resp = yield from iot.async_handle_alexa( hass, hass.data['cloud'], @@ -377,7 +378,7 @@ def test_handler_google_actions(hass): async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): """Test handler Google Actions when user has disabled it.""" - mock_cloud_fixture[STORAGE_ENABLE_GOOGLE] = False + mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): From 377730a37cf430c5fa93a8baad2b0c36f0865530 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 20 Nov 2018 16:05:25 -0700 Subject: [PATCH 212/238] Change channel with play_media instead of select_source (#18474) * Use service play_media instead of select_source Use service play_media instead of select_source to change the channel as play_media is the right service for that. * Log error on invalid media type Log an error instead of raising a NotImplementedError if an invalid media type is provided. * Changed so that success is not in else statement Updated so that if media_type is channel that it is not in the else of an if. * Update directv.py Removed SELECT_SOURCE as supported feature. * Rebased Re-based with dev --- .../components/media_player/directv.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 51f5cbc5bb0..7a1e240d82e 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -9,9 +9,9 @@ import requests import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_MOVIE, MEDIA_TYPE_TVSHOW, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, - SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, - SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_MOVIE, MEDIA_TYPE_TVSHOW, PLATFORM_SCHEMA, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_PAUSED, @@ -33,12 +33,12 @@ DEFAULT_NAME = "DirecTV Receiver" DEFAULT_PORT = 8080 SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ - SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY SUPPORT_DTV_CLIENT = SUPPORT_PAUSE | \ - SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ - SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY DATA_DIRECTV = 'data_directv' @@ -375,7 +375,12 @@ class DirecTvDevice(MediaPlayerDevice): _LOGGER.debug("Fast forward on %s", self._name) self.dtv.key_press('ffwd') - def select_source(self, source): + def play_media(self, media_type, media_id, **kwargs): """Select input source.""" - _LOGGER.debug("Changing channel on %s to %s", self._name, source) - self.dtv.tune_channel(source) + if media_type != MEDIA_TYPE_CHANNEL: + _LOGGER.error("Invalid media type %s. Only %s is supported", + media_type, MEDIA_TYPE_CHANNEL) + return + + _LOGGER.debug("Changing channel on %s to %s", self._name, media_id) + self.dtv.tune_channel(media_id) From 3b53003795f5b11cb3a3eb263125f46c92f0145c Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Wed, 21 Nov 2018 06:15:54 +0100 Subject: [PATCH 213/238] Fibaro components (#18487) * Added Fibaro omcponents Added cover, light, sensor and switch components * Improvements based on code review Improvements based on code review * Fixes based on code review Fixes based on code review * Changes to light behavior based on code review Changes to light behavior based on code review * Internal changes Changed how brightness is represented internally. It should have no impact on functionality. --- homeassistant/components/cover/fibaro.py | 92 ++++++++++++ homeassistant/components/fibaro.py | 29 ++-- homeassistant/components/light/fibaro.py | 165 ++++++++++++++++++++++ homeassistant/components/sensor/fibaro.py | 99 +++++++++++++ homeassistant/components/switch/fibaro.py | 68 +++++++++ 5 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/cover/fibaro.py create mode 100644 homeassistant/components/light/fibaro.py create mode 100644 homeassistant/components/sensor/fibaro.py create mode 100644 homeassistant/components/switch/fibaro.py diff --git a/homeassistant/components/cover/fibaro.py b/homeassistant/components/cover/fibaro.py new file mode 100644 index 00000000000..dc82087f802 --- /dev/null +++ b/homeassistant/components/cover/fibaro.py @@ -0,0 +1,92 @@ +""" +Support for Fibaro cover - curtains, rollershutters etc. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.fibaro/ +""" +import logging + +from homeassistant.components.cover import ( + CoverDevice, ENTITY_ID_FORMAT, ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro covers.""" + if discovery_info is None: + return + + add_entities( + [FibaroCover(device, hass.data[FIBARO_CONTROLLER]) for + device in hass.data[FIBARO_DEVICES]['cover']], True) + + +class FibaroCover(FibaroDevice, CoverDevice): + """Representation a Fibaro Cover.""" + + def __init__(self, fibaro_device, controller): + """Initialize the Vera device.""" + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + @staticmethod + def bound(position): + """Normalize the position.""" + if position is None: + return None + position = int(position) + if position <= 5: + return 0 + if position >= 95: + return 100 + return position + + @property + def current_cover_position(self): + """Return current position of cover. 0 is closed, 100 is open.""" + return self.bound(self.level) + + @property + def current_cover_tilt_position(self): + """Return the current tilt position for venetian blinds.""" + return self.bound(self.level2) + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self.set_level(kwargs.get(ATTR_POSITION)) + + def set_cover_tilt_position(self, **kwargs): + """Move the cover to a specific position.""" + self.set_level2(kwargs.get(ATTR_TILT_POSITION)) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is None: + return None + return self.current_cover_position == 0 + + def open_cover(self, **kwargs): + """Open the cover.""" + self.action("open") + + def close_cover(self, **kwargs): + """Close the cover.""" + self.action("close") + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + self.set_level2(100) + + def close_cover_tilt(self, **kwargs): + """Close the cover.""" + self.set_level2(0) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.action("stop") diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index 9a9e5b12851..c9dd19b4bc8 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -23,14 +23,11 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'fibaro' FIBARO_DEVICES = 'fibaro_devices' FIBARO_CONTROLLER = 'fibaro_controller' -FIBARO_ID_FORMAT = '{}_{}_{}' ATTR_CURRENT_POWER_W = "current_power_w" ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" CONF_PLUGINS = "plugins" -FIBARO_COMPONENTS = [ - 'binary_sensor', -] +FIBARO_COMPONENTS = ['binary_sensor', 'cover', 'light', 'sensor', 'switch'] FIBARO_TYPEMAP = { 'com.fibaro.multilevelSensor': "sensor", @@ -174,7 +171,7 @@ class FibaroController(): else: room_name = self._room_map[device.roomID].name device.friendly_name = room_name + ' ' + device.name - device.ha_id = FIBARO_ID_FORMAT.format( + device.ha_id = '{}_{}_{}'.format( slugify(room_name), slugify(device.name), device.id) self._device_map[device.id] = device self.fibaro_devices = defaultdict(list) @@ -232,13 +229,15 @@ class FibaroDevice(Entity): """Update the state.""" self.schedule_update_ha_state(True) - def get_level(self): + @property + def level(self): """Get the level of Fibaro device.""" if 'value' in self.fibaro_device.properties: return self.fibaro_device.properties.value return None - def get_level2(self): + @property + def level2(self): """Get the tilt level of Fibaro device.""" if 'value2' in self.fibaro_device.properties: return self.fibaro_device.properties.value2 @@ -258,7 +257,21 @@ class FibaroDevice(Entity): if 'brightness' in self.fibaro_device.properties: self.fibaro_device.properties.brightness = level - def set_color(self, red, green, blue, white): + def set_level2(self, level): + """Set the level2 of Fibaro device.""" + self.action("setValue2", level) + if 'value2' in self.fibaro_device.properties: + self.fibaro_device.properties.value2 = level + + def call_turn_on(self): + """Turn on the Fibaro device.""" + self.action("turnOn") + + def call_turn_off(self): + """Turn off the Fibaro device.""" + self.action("turnOff") + + def call_set_color(self, red, green, blue, white): """Set the color of Fibaro device.""" color_str = "{},{},{},{}".format(int(red), int(green), int(blue), int(white)) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py new file mode 100644 index 00000000000..cfc28e12218 --- /dev/null +++ b/homeassistant/components/light/fibaro.py @@ -0,0 +1,165 @@ +""" +Support for Fibaro lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.fibaro/ +""" + +import logging +import threading + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light) +import homeassistant.util.color as color_util +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['fibaro'] + + +def scaleto255(value): + """Scale the input value from 0-100 to 0-255.""" + # Fibaro has a funny way of storing brightness either 0-100 or 0-99 + # depending on device type (e.g. dimmer vs led) + if value > 98: + value = 100 + return max(0, min(255, ((value * 256.0) / 100.0))) + + +def scaleto100(value): + """Scale the input value from 0-255 to 0-100.""" + # Make sure a low but non-zero value is not rounded down to zero + if 0 < value < 3: + return 1 + return max(0, min(100, ((value * 100.4) / 255.0))) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Perform the setup for Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroLight(device, hass.data[FIBARO_CONTROLLER]) + for device in hass.data[FIBARO_DEVICES]['light']], True) + + +class FibaroLight(FibaroDevice, Light): + """Representation of a Fibaro Light, including dimmable.""" + + def __init__(self, fibaro_device, controller): + """Initialize the light.""" + self._supported_flags = 0 + self._last_brightness = 0 + self._color = (0, 0) + self._brightness = None + self._white = 0 + + self._update_lock = threading.RLock() + if 'levelChange' in fibaro_device.interfaces: + self._supported_flags |= SUPPORT_BRIGHTNESS + if 'color' in fibaro_device.properties: + self._supported_flags |= SUPPORT_COLOR + if 'setW' in fibaro_device.actions: + self._supported_flags |= SUPPORT_WHITE_VALUE + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + @property + def brightness(self): + """Return the brightness of the light.""" + return scaleto255(self._brightness) + + @property + def hs_color(self): + """Return the color of the light.""" + return self._color + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._white + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_flags + + def turn_on(self, **kwargs): + """Turn the light on.""" + with self._update_lock: + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_BRIGHTNESS) + + # No brightness specified, so we either restore it to + # last brightness or switch it on at maximum level + if target_brightness is None: + if self._brightness == 0: + if self._last_brightness: + self._brightness = self._last_brightness + else: + self._brightness = 100 + else: + # We set it to the target brightness and turn it on + self._brightness = scaleto100(target_brightness) + + if self._supported_flags & SUPPORT_COLOR: + # Update based on parameters + self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) + self._color = kwargs.get(ATTR_HS_COLOR, self._color) + rgb = color_util.color_hs_to_RGB(*self._color) + self.call_set_color( + int(rgb[0] * self._brightness / 99.0 + 0.5), + int(rgb[1] * self._brightness / 99.0 + 0.5), + int(rgb[2] * self._brightness / 99.0 + 0.5), + int(self._white * self._brightness / 99.0 + + 0.5)) + if self.state == 'off': + self.set_level(int(self._brightness)) + return + + if self._supported_flags & SUPPORT_BRIGHTNESS: + self.set_level(int(self._brightness)) + return + + # The simplest case is left for last. No dimming, just switch on + self.call_turn_on() + + def turn_off(self, **kwargs): + """Turn the light off.""" + # Let's save the last brightness level before we switch it off + with self._update_lock: + if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ + self._brightness and self._brightness > 0: + self._last_brightness = self._brightness + self._brightness = 0 + self.call_turn_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self.current_binary_state + + def update(self): + """Call to update state.""" + # Brightness handling + with self._update_lock: + if self._supported_flags & SUPPORT_BRIGHTNESS: + self._brightness = float(self.fibaro_device.properties.value) + # Color handling + if self._supported_flags & SUPPORT_COLOR: + # Fibaro communicates the color as an 'R, G, B, W' string + rgbw_s = self.fibaro_device.properties.color + if rgbw_s == '0,0,0,0' and\ + 'lastColorSet' in self.fibaro_device.properties: + rgbw_s = self.fibaro_device.properties.lastColorSet + rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] + if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: + self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) + if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ + self.brightness != 0: + self._white = min(255, max(0, rgbw_list[3]*100.0 / + self._brightness)) diff --git a/homeassistant/components/sensor/fibaro.py b/homeassistant/components/sensor/fibaro.py new file mode 100644 index 00000000000..e5ed5638c5b --- /dev/null +++ b/homeassistant/components/sensor/fibaro.py @@ -0,0 +1,99 @@ +""" +Support for Fibaro sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fibaro/ +""" +import logging + +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +SENSOR_TYPES = { + 'com.fibaro.temperatureSensor': + ['Temperature', None, None, DEVICE_CLASS_TEMPERATURE], + 'com.fibaro.smokeSensor': + ['Smoke', 'ppm', 'mdi:fire', None], + 'CO2': + ['CO2', 'ppm', 'mdi:cloud', None], + 'com.fibaro.humiditySensor': + ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], + 'com.fibaro.lightSensor': + ['Light', 'lx', None, DEVICE_CLASS_ILLUMINANCE] +} + +DEPENDENCIES = ['fibaro'] +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroSensor(device, hass.data[FIBARO_CONTROLLER]) + for device in hass.data[FIBARO_DEVICES]['sensor']], True) + + +class FibaroSensor(FibaroDevice, Entity): + """Representation of a Fibaro Sensor.""" + + def __init__(self, fibaro_device, controller): + """Initialize the sensor.""" + self.current_value = None + self.last_changed_time = None + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + if fibaro_device.type in SENSOR_TYPES: + self._unit = SENSOR_TYPES[fibaro_device.type][1] + self._icon = SENSOR_TYPES[fibaro_device.type][2] + self._device_class = SENSOR_TYPES[fibaro_device.type][3] + else: + self._unit = None + self._icon = None + self._device_class = None + try: + if not self._unit: + if self.fibaro_device.properties.unit == 'lux': + self._unit = 'lx' + elif self.fibaro_device.properties.unit == 'C': + self._unit = TEMP_CELSIUS + elif self.fibaro_device.properties.unit == 'F': + self._unit = TEMP_FAHRENHEIT + else: + self._unit = self.fibaro_device.properties.unit + except (KeyError, ValueError): + pass + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + def update(self): + """Update the state.""" + try: + self.current_value = float(self.fibaro_device.properties.value) + except (KeyError, ValueError): + pass diff --git a/homeassistant/components/switch/fibaro.py b/homeassistant/components/switch/fibaro.py new file mode 100644 index 00000000000..d3e96646a45 --- /dev/null +++ b/homeassistant/components/switch/fibaro.py @@ -0,0 +1,68 @@ +""" +Support for Fibaro switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.fibaro/ +""" +import logging + +from homeassistant.util import convert +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro switches.""" + if discovery_info is None: + return + + add_entities( + [FibaroSwitch(device, hass.data[FIBARO_CONTROLLER]) for + device in hass.data[FIBARO_DEVICES]['switch']], True) + + +class FibaroSwitch(FibaroDevice, SwitchDevice): + """Representation of a Fibaro Switch.""" + + def __init__(self, fibaro_device, controller): + """Initialize the Fibaro device.""" + self._state = False + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + def turn_on(self, **kwargs): + """Turn device on.""" + self.call_turn_on() + self._state = True + + def turn_off(self, **kwargs): + """Turn device off.""" + self.call_turn_off() + self._state = False + + @property + def current_power_w(self): + """Return the current power usage in W.""" + if 'power' in self.fibaro_device.interfaces: + return convert(self.fibaro_device.properties.power, float, 0.0) + return None + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + if 'energy' in self.fibaro_device.interfaces: + return convert(self.fibaro_device.properties.energy, float, 0.0) + return None + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def update(self): + """Update device state.""" + self._state = self.current_binary_state From 8aa2cefd7575ca3158bbafd1cdfd27a55c5103ba Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 21 Nov 2018 02:57:59 -0500 Subject: [PATCH 214/238] Upgrade blinkpy to 0.10.3 (Fixes #18341) (#18603) --- homeassistant/components/blink/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 66cfe3990a3..62e73a52cc8 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.10.1'] +REQUIREMENTS = ['blinkpy==0.10.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 13dd968804a..b6e21143e30 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ bellows==0.7.0 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.10.1 +blinkpy==0.10.3 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 From 36c31a629356616c08af27f9d37ebdbf0cc19d3b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 12:26:08 +0100 Subject: [PATCH 215/238] Add permissions check in service helper (#18596) * Add permissions check in service helper * Lint * Fix tests * Lint * Typing * Fix unused impoert --- homeassistant/auth/__init__.py | 4 + homeassistant/auth/auth_store.py | 8 ++ homeassistant/exceptions.py | 35 +++++-- homeassistant/helpers/service.py | 69 ++++++++++-- tests/components/conftest.py | 17 +++ tests/components/counter/test_init.py | 6 +- tests/components/light/test_init.py | 6 +- tests/components/switch/test_init.py | 6 +- tests/components/test_input_boolean.py | 6 +- tests/components/test_input_datetime.py | 6 +- tests/components/test_input_number.py | 6 +- tests/components/test_input_select.py | 6 +- tests/components/test_input_text.py | 6 +- tests/helpers/test_service.py | 133 +++++++++++++++++++++++- 14 files changed, 268 insertions(+), 46 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 0011c98ce73..e69dec37df2 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -118,6 +118,10 @@ class AuthManager: """Retrieve a user.""" return await self._store.async_get_user(user_id) + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all groups.""" + return await self._store.async_get_group(group_id) + async def async_get_user_by_credentials( self, credentials: models.Credentials) -> Optional[models.User]: """Get a user by credential, return None if not found.""" diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index ab233489db0..867d5357a58 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -45,6 +45,14 @@ class AuthStore: return list(self._groups.values()) + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return self._groups.get(group_id) + async def async_get_users(self) -> List[models.User]: """Retrieve all users.""" if self._users is None: diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 11aa1848529..0613b7cb10c 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,24 +1,24 @@ """The exceptions used by Home Assistant.""" +from typing import Optional, Tuple, TYPE_CHECKING import jinja2 +# pylint: disable=using-constant-test +if TYPE_CHECKING: + # pylint: disable=unused-import + from .core import Context # noqa + class HomeAssistantError(Exception): """General Home Assistant exception occurred.""" - pass - class InvalidEntityFormatError(HomeAssistantError): """When an invalid formatted entity is encountered.""" - pass - class NoEntitySpecifiedError(HomeAssistantError): """When no entity is specified.""" - pass - class TemplateError(HomeAssistantError): """Error during template rendering.""" @@ -32,16 +32,29 @@ class TemplateError(HomeAssistantError): class PlatformNotReady(HomeAssistantError): """Error to indicate that platform is not ready.""" - pass - class ConfigEntryNotReady(HomeAssistantError): """Error to indicate that config entry is not ready.""" - pass - class InvalidStateError(HomeAssistantError): """When an invalid state is encountered.""" - pass + +class Unauthorized(HomeAssistantError): + """When an action is unauthorized.""" + + def __init__(self, context: Optional['Context'] = None, + user_id: Optional[str] = None, + entity_id: Optional[str] = None, + permission: Optional[Tuple[str]] = None) -> None: + """Unauthorized error.""" + super().__init__(self.__class__.__name__) + self.context = context + self.user_id = user_id + self.entity_id = entity_id + self.permission = permission + + +class UnknownUser(Unauthorized): + """When call is made with user ID that doesn't exist.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 0f394a6f153..5e0d9c7e88a 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -5,9 +5,10 @@ from os import path import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.const import ATTR_ENTITY_ID import homeassistant.core as ha -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import TemplateError, Unauthorized, UnknownUser from homeassistant.helpers import template from homeassistant.loader import get_component, bind_hass from homeassistant.util.yaml import load_yaml @@ -187,23 +188,75 @@ async def entity_service_call(hass, platforms, func, call): Calls all platforms simultaneously. """ - tasks = [] - all_entities = ATTR_ENTITY_ID not in call.data - if not all_entities: + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + perms = user.permissions + else: + perms = None + + # Are we trying to target all entities + target_all_entities = ATTR_ENTITY_ID not in call.data + + if not target_all_entities: + # A set of entities we're trying to target. entity_ids = set( extract_entity_ids(hass, call, True)) + # If the service function is a string, we'll pass it the service call data if isinstance(func, str): data = {key: val for key, val in call.data.items() if key != ATTR_ENTITY_ID} + # If the service function is not a string, we pass the service call else: data = call + # Check the permissions + + # A list with for each platform in platforms a list of entities to call + # the service on. + platforms_entities = [] + + if perms is None: + for platform in platforms: + if target_all_entities: + platforms_entities.append(list(platform.entities.values())) + else: + platforms_entities.append([ + entity for entity in platform.entities.values() + if entity.entity_id in entity_ids + ]) + + elif target_all_entities: + # If we target all entities, we will select all entities the user + # is allowed to control. + for platform in platforms: + platforms_entities.append([ + entity for entity in platform.entities.values() + if perms.check_entity(entity.entity_id, POLICY_CONTROL)]) + + else: + for platform in platforms: + platform_entities = [] + for entity in platform.entities.values(): + if entity.entity_id not in entity_ids: + continue + + if not perms.check_entity(entity.entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + entity_id=entity.entity_id, + permission=POLICY_CONTROL + ) + + platform_entities.append(entity) + + platforms_entities.append(platform_entities) + tasks = [ - _handle_service_platform_call(func, data, [ - entity for entity in platform.entities.values() - if all_entities or entity.entity_id in entity_ids - ], call.context) for platform in platforms + _handle_service_platform_call(func, data, entities, call.context) + for platform, entities in zip(platforms, platforms_entities) ] if tasks: diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 252d0b1d872..2568a109244 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( @@ -77,3 +78,19 @@ def hass_access_token(hass): refresh_token = hass.loop.run_until_complete( hass.auth.async_create_refresh_token(user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) + + +@pytest.fixture +def hass_admin_user(hass): + """Return a Home Assistant admin user.""" + admin_group = hass.loop.run_until_complete(hass.auth.async_get_group( + GROUP_ID_ADMIN)) + return MockUser(groups=[admin_group]).add_to_hass(hass) + + +@pytest.fixture +def hass_read_only_user(hass): + """Return a Home Assistant read only user.""" + read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group( + GROUP_ID_READ_ONLY)) + return MockUser(groups=[read_only_group]).add_to_hass(hass) diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 78ca72dd1e4..c8411bf2fde 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -234,7 +234,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert int(state.state) == 0 -async def test_counter_context(hass): +async def test_counter_context(hass, hass_admin_user): """Test that counter context works.""" assert await async_setup_component(hass, 'counter', { 'counter': { @@ -247,9 +247,9 @@ async def test_counter_context(hass): await hass.services.async_call('counter', 'increment', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('counter.test') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index a04fb853996..09474a5ad06 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -476,7 +476,7 @@ async def test_intent_set_color_and_brightness(hass): assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20 -async def test_light_context(hass): +async def test_light_context(hass, hass_admin_user): """Test that light context works.""" assert await async_setup_component(hass, 'light', { 'light': { @@ -489,9 +489,9 @@ async def test_light_context(hass): await hass.services.async_call('light', 'toggle', { 'entity_id': state.entity_id, - }, True, core.Context(user_id='abcd')) + }, True, core.Context(user_id=hass_admin_user.id)) state2 = hass.states.get('light.ceiling') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index 1a51457df96..d39c5a24ddc 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -91,7 +91,7 @@ class TestSwitch(unittest.TestCase): ) -async def test_switch_context(hass): +async def test_switch_context(hass, hass_admin_user): """Test that switch context works.""" assert await async_setup_component(hass, 'switch', { 'switch': { @@ -104,9 +104,9 @@ async def test_switch_context(hass): await hass.services.async_call('switch', 'toggle', { 'entity_id': state.entity_id, - }, True, core.Context(user_id='abcd')) + }, True, core.Context(user_id=hass_admin_user.id)) state2 = hass.states.get('switch.ac') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_boolean.py b/tests/components/test_input_boolean.py index a77e0a8c010..019318c2693 100644 --- a/tests/components/test_input_boolean.py +++ b/tests/components/test_input_boolean.py @@ -147,7 +147,7 @@ def test_initial_state_overrules_restore_state(hass): assert state.state == 'on' -async def test_input_boolean_context(hass): +async def test_input_boolean_context(hass, hass_admin_user): """Test that input_boolean context works.""" assert await async_setup_component(hass, 'input_boolean', { 'input_boolean': { @@ -160,9 +160,9 @@ async def test_input_boolean_context(hass): await hass.services.async_call('input_boolean', 'turn_off', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_boolean.ac') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py index 9649531a8a1..a61cefe34f2 100644 --- a/tests/components/test_input_datetime.py +++ b/tests/components/test_input_datetime.py @@ -195,7 +195,7 @@ def test_restore_state(hass): assert state_bogus.state == str(initial) -async def test_input_datetime_context(hass): +async def test_input_datetime_context(hass, hass_admin_user): """Test that input_datetime context works.""" assert await async_setup_component(hass, 'input_datetime', { 'input_datetime': { @@ -211,9 +211,9 @@ async def test_input_datetime_context(hass): await hass.services.async_call('input_datetime', 'set_datetime', { 'entity_id': state.entity_id, 'date': '2018-01-02' - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_datetime.only_date') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_number.py b/tests/components/test_input_number.py index 354c67b4d1b..70dfeec2e7f 100644 --- a/tests/components/test_input_number.py +++ b/tests/components/test_input_number.py @@ -266,7 +266,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert float(state.state) == 0 -async def test_input_number_context(hass): +async def test_input_number_context(hass, hass_admin_user): """Test that input_number context works.""" assert await async_setup_component(hass, 'input_number', { 'input_number': { @@ -282,9 +282,9 @@ async def test_input_number_context(hass): await hass.services.async_call('input_number', 'increment', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_number.b1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_select.py b/tests/components/test_input_select.py index f37566ffd73..528560edc04 100644 --- a/tests/components/test_input_select.py +++ b/tests/components/test_input_select.py @@ -302,7 +302,7 @@ def test_initial_state_overrules_restore_state(hass): assert state.state == 'middle option' -async def test_input_select_context(hass): +async def test_input_select_context(hass, hass_admin_user): """Test that input_select context works.""" assert await async_setup_component(hass, 'input_select', { 'input_select': { @@ -321,9 +321,9 @@ async def test_input_select_context(hass): await hass.services.async_call('input_select', 'select_next', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_select.s1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py index 7e8cec6ff80..f0dec42ccea 100644 --- a/tests/components/test_input_text.py +++ b/tests/components/test_input_text.py @@ -184,7 +184,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert str(state.state) == 'unknown' -async def test_input_text_context(hass): +async def test_input_text_context(hass, hass_admin_user): """Test that input_text context works.""" assert await async_setup_component(hass, 'input_text', { 'input_text': { @@ -200,9 +200,9 @@ async def test_input_text_context(hass): await hass.services.async_call('input_text', 'set_value', { 'entity_id': state.entity_id, 'value': 'new_value', - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_text.t1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 71775574c28..a4e9a571943 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,18 +1,49 @@ """Test service helpers.""" import asyncio +from collections import OrderedDict from copy import deepcopy import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch + +import pytest # To prevent circular import when running just this file import homeassistant.components # noqa -from homeassistant import core as ha, loader +from homeassistant import core as ha, loader, exceptions from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID from homeassistant.helpers import service, template from homeassistant.setup import async_setup_component import homeassistant.helpers.config_validation as cv +from homeassistant.auth.permissions import PolicyPermissions -from tests.common import get_test_home_assistant, mock_service +from tests.common import get_test_home_assistant, mock_service, mock_coro + + +@pytest.fixture +def mock_service_platform_call(): + """Mock service platform call.""" + with patch('homeassistant.helpers.service._handle_service_platform_call', + side_effect=lambda *args: mock_coro()) as mock_call: + yield mock_call + + +@pytest.fixture +def mock_entities(): + """Return mock entities in an ordered dict.""" + kitchen = Mock( + entity_id='light.kitchen', + available=True, + should_poll=False, + ) + living_room = Mock( + entity_id='light.living_room', + available=True, + should_poll=False, + ) + entities = OrderedDict() + entities[kitchen.entity_id] = kitchen + entities[living_room.entity_id] = living_room + return entities class TestServiceHelpers(unittest.TestCase): @@ -179,3 +210,99 @@ def test_async_get_all_descriptions(hass): assert 'description' in descriptions[logger.DOMAIN]['set_level'] assert 'fields' in descriptions[logger.DOMAIN]['set_level'] + + +async def test_call_context_user_not_exist(hass): + """Check we don't allow deleted users to do things.""" + with pytest.raises(exceptions.UnknownUser) as err: + await service.entity_service_call(hass, [], Mock(), ha.ServiceCall( + 'test_domain', 'test_service', context=ha.Context( + user_id='non-existing'))) + + assert err.value.context.user_id == 'non-existing' + + +async def test_call_context_target_all(hass, mock_service_platform_call, + mock_entities): + """Check we only target allowed entities if targetting all.""" + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock(permissions=PolicyPermissions({ + 'entities': { + 'entity_ids': { + 'light.kitchen': True + } + } + })))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', + context=ha.Context(user_id='mock-id'))) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] + + +async def test_call_context_target_specific(hass, mock_service_platform_call, + mock_entities): + """Check targeting specific entities.""" + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock(permissions=PolicyPermissions({ + 'entities': { + 'entity_ids': { + 'light.kitchen': True + } + } + })))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': 'light.kitchen' + }, context=ha.Context(user_id='mock-id'))) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] + + +async def test_call_context_target_specific_no_auth( + hass, mock_service_platform_call, mock_entities): + """Check targeting specific entities without auth.""" + with pytest.raises(exceptions.Unauthorized) as err: + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock( + permissions=PolicyPermissions({})))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': 'light.kitchen' + }, context=ha.Context(user_id='mock-id'))) + + assert err.value.context.user_id == 'mock-id' + assert err.value.entity_id == 'light.kitchen' + + +async def test_call_no_context_target_all(hass, mock_service_platform_call, + mock_entities): + """Check we target all if no user context given.""" + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service')) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == list(mock_entities.values()) + + +async def test_call_no_context_target_specific( + hass, mock_service_platform_call, mock_entities): + """Check we can target specified entities.""" + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': ['light.kitchen', 'light.non-existing'] + })) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] From 3cde8dc3a95094a8f9e4fe17bda57ddf05143e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 21 Nov 2018 12:38:42 +0100 Subject: [PATCH 216/238] Add support for HTTPS and basic HTTP authentication for Glances (#18608) * Add support for SSL and basic HTTP auth * Remove blank line at the end of the file --- homeassistant/components/sensor/glances.py | 30 ++++++++++++++-------- requirements_all.txt | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index c2127827ebd..1dfb7a206c6 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -11,14 +11,15 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_RESOURCES, TEMP_CELSIUS) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, + CONF_VERIFY_SSL, CONF_RESOURCES, TEMP_CELSIUS) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['glances_api==0.1.0'] +REQUIREMENTS = ['glances_api==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -54,8 +55,12 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, vol.Optional(CONF_RESOURCES, default=['disk_use']): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In([2, 3]), @@ -67,15 +72,20 @@ async def async_setup_platform( """Set up the Glances sensors.""" from glances_api import Glances - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - version = config.get(CONF_VERSION) - var_conf = config.get(CONF_RESOURCES) + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] + version = config[CONF_VERSION] + var_conf = config[CONF_RESOURCES] + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + ssl = config[CONF_SSL] + verify_ssl = config[CONF_VERIFY_SSL] - session = async_get_clientsession(hass) + session = async_get_clientsession(hass, verify_ssl) glances = GlancesData( - Glances(hass.loop, session, host=host, port=port, version=version)) + Glances(hass.loop, session, host=host, port=port, version=version, + username=username, password=password, ssl=ssl)) await glances.async_update() diff --git a/requirements_all.txt b/requirements_all.txt index b6e21143e30..2df07efc4fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ ghlocalapi==0.1.0 gitterpy==0.1.7 # homeassistant.components.sensor.glances -glances_api==0.1.0 +glances_api==0.2.0 # homeassistant.components.notify.gntp gntp==1.0.3 From 1e3930a447201c9198f289377a7121147d059612 Mon Sep 17 00:00:00 2001 From: Jonathan McDowell Date: Wed, 21 Nov 2018 13:22:24 +0000 Subject: [PATCH 217/238] Add support for Panasonic Blu-Ray players (#18541) * Add support for Panasonic Blu-Ray players * Update panasonic_bluray.py * Update panasonic_bluray.py --- .coveragerc | 1 + .../media_player/panasonic_bluray.py | 154 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 158 insertions(+) create mode 100644 homeassistant/components/media_player/panasonic_bluray.py diff --git a/.coveragerc b/.coveragerc index a4fd6ea1c2e..2a6446092e5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -600,6 +600,7 @@ omit = homeassistant/components/media_player/nadtcp.py homeassistant/components/media_player/onkyo.py homeassistant/components/media_player/openhome.py + homeassistant/components/media_player/panasonic_bluray.py homeassistant/components/media_player/panasonic_viera.py homeassistant/components/media_player/pandora.py homeassistant/components/media_player/philips_js.py diff --git a/homeassistant/components/media_player/panasonic_bluray.py b/homeassistant/components/media_player/panasonic_bluray.py new file mode 100644 index 00000000000..bcd34f162c7 --- /dev/null +++ b/homeassistant/components/media_player/panasonic_bluray.py @@ -0,0 +1,154 @@ +""" +Support for Panasonic Blu-Ray players. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/panasonic_bluray/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.dt import utcnow + +REQUIREMENTS = ['panacotta==0.1'] + +DEFAULT_NAME = "Panasonic Blu-Ray" +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_PANASONIC_BD = (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY | + SUPPORT_STOP | SUPPORT_PAUSE) + +# No host is needed for configuration, however it can be set. +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Panasonic Blu-Ray platform.""" + conf = discovery_info if discovery_info else config + + # Register configured device with Home Assistant. + add_entities([PanasonicBluRay(conf[CONF_HOST], conf[CONF_NAME])]) + + +class PanasonicBluRay(MediaPlayerDevice): + """Represent Panasonic Blu-Ray devices for Home Assistant.""" + + def __init__(self, ip, name): + """Receive IP address and name to construct class.""" + # Import panacotta library. + import panacotta + + # Initialize the Panasonic device. + self._device = panacotta.PanasonicBD(ip) + # Default name value, only to be overridden by user. + self._name = name + # Assume we're off to start with + self._state = STATE_OFF + self._position = 0 + self._duration = 0 + self._position_valid = 0 + + @property + def icon(self): + """Return a disc player icon for the device.""" + return 'mdi:disc-player' + + @property + def name(self): + """Return the display name of this device.""" + return self._name + + @property + def state(self): + """Return _state variable, containing the appropriate constant.""" + return self._state + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_PANASONIC_BD + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid.""" + return self._position_valid + + def update(self): + """Update the internal state by querying the device.""" + # This can take 5+ seconds to complete + state = self._device.get_play_status() + + if state[0] == 'error': + self._state = STATE_UNKNOWN + elif state[0] in ['off', 'standby']: + # We map both of these to off. If it's really off we can't + # turn it on, but from standby we can go to idle by pressing + # POWER. + self._state = STATE_OFF + elif state[0] in ['paused', 'stopped']: + self._state = STATE_IDLE + elif state[0] == 'playing': + self._state = STATE_PLAYING + + # Update our current media position + length + if state[1] >= 0: + self._position = state[1] + else: + self._position = 0 + self._position_valid = utcnow() + self._duration = state[2] + + def turn_off(self): + """ + Instruct the device to turn standby. + + Sending the "POWER" button will turn the device to standby - there + is no way to turn it completely off remotely. However this works in + our favour as it means the device is still accepting commands and we + can thus turn it back on when desired. + """ + if self._state != STATE_OFF: + self._device.send_key('POWER') + + self._state = STATE_OFF + + def turn_on(self): + """Wake the device back up from standby.""" + if self._state == STATE_OFF: + self._device.send_key('POWER') + + self._state = STATE_IDLE + + def media_play(self): + """Send play command.""" + self._device.send_key('PLAYBACK') + + def media_pause(self): + """Send pause command.""" + self._device.send_key('PAUSE') + + def media_stop(self): + """Send stop command.""" + self._device.send_key('STOP') diff --git a/requirements_all.txt b/requirements_all.txt index 2df07efc4fd..d6c5bc82b92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -716,6 +716,9 @@ orvibo==1.1.1 # homeassistant.components.shiftr paho-mqtt==1.4.0 +# homeassistant.components.media_player.panasonic_bluray +panacotta==0.1 + # homeassistant.components.media_player.panasonic_viera panasonic_viera==0.3.1 From 81cac33801fced429b20f9743c8cc45f481f6432 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Wed, 21 Nov 2018 15:13:20 +0100 Subject: [PATCH 218/238] Update locationsharinglib requirement to 3.0.8 (#18612) --- homeassistant/components/device_tracker/google_maps.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 94a2033e7c0..1995179ff5a 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['locationsharinglib==3.0.7'] +REQUIREMENTS = ['locationsharinglib==3.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d6c5bc82b92..0672b553eed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -594,7 +594,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==3.0.7 +locationsharinglib==3.0.8 # homeassistant.components.logi_circle logi_circle==0.1.7 From 92c0f9e4aa85f9cf475e882a010f738edef6098f Mon Sep 17 00:00:00 2001 From: Pawel Date: Wed, 21 Nov 2018 15:48:44 +0100 Subject: [PATCH 219/238] Fix mqtt cover inverted (#18456) * Fixed state and position retrieval in inverted mode 100-0 * Always calculating find_percentage_in_range * Added usage of max/min functions. --- homeassistant/components/cover/mqtt.py | 25 +++++---- tests/components/cover/test_mqtt.py | 75 +++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 235b28b5be2..f51cca8a276 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -279,21 +279,19 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) + if payload.isnumeric(): - if 0 <= int(payload) <= 100: - percentage_payload = int(payload) - else: - percentage_payload = self.find_percentage_in_range( - float(payload), COVER_PAYLOAD) - if 0 <= percentage_payload <= 100: - self._position = percentage_payload - self._state = self._position == self._position_closed + percentage_payload = self.find_percentage_in_range( + float(payload), COVER_PAYLOAD) + self._position = percentage_payload + self._state = percentage_payload == DEFAULT_POSITION_CLOSED else: _LOGGER.warning( "Payload is not integer within range: %s", payload) return self.async_schedule_update_ha_state() + if self._get_position_topic: await mqtt.async_subscribe( self.hass, self._get_position_topic, @@ -374,7 +372,8 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Optimistically assume that cover has changed state. self._state = False if self._get_position_topic: - self._position = self._position_open + self._position = self.find_percentage_in_range( + self._position_open, COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_close_cover(self, **kwargs): @@ -389,7 +388,8 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Optimistically assume that cover has changed state. self._state = True if self._get_position_topic: - self._position = self._position_closed + self._position = self.find_percentage_in_range( + self._position_closed, COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_stop_cover(self, **kwargs): @@ -469,6 +469,11 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, offset_position = position - min_range position_percentage = round( float(offset_position) / current_range * 100.0) + + max_percent = 100 + min_percent = 0 + position_percentage = min(max(position_percentage, min_percent), + max_percent) if range_type == TILT_PAYLOAD and self._tilt_invert: return 100 - position_percentage return position_percentage diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 81c0848c4c5..26204ce6ebd 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -308,18 +308,81 @@ class TestCoverMQTT(unittest.TestCase): 'cover.test').attributes['current_position'] assert 50 == current_cover_position - fire_mqtt_message(self.hass, 'get-position-topic', '101') - self.hass.block_till_done() - current_cover_position = self.hass.states.get( - 'cover.test').attributes['current_position'] - assert 50 == current_cover_position - fire_mqtt_message(self.hass, 'get-position-topic', 'non-numeric') self.hass.block_till_done() current_cover_position = self.hass.states.get( 'cover.test').attributes['current_position'] assert 50 == current_cover_position + fire_mqtt_message(self.hass, 'get-position-topic', '101') + self.hass.block_till_done() + current_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 100 == current_cover_position + + def test_current_cover_position_inverted(self): + """Test the current cover position.""" + assert setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'position_topic': 'get-position-topic', + 'command_topic': 'command-topic', + 'position_open': 0, + 'position_closed': 100, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP' + } + }) + + state_attributes_dict = self.hass.states.get( + 'cover.test').attributes + assert not ('current_position' in state_attributes_dict) + assert not ('current_tilt_position' in state_attributes_dict) + assert not (4 & self.hass.states.get( + 'cover.test').attributes['supported_features'] == 4) + + fire_mqtt_message(self.hass, 'get-position-topic', '100') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 0 == current_percentage_cover_position + assert STATE_CLOSED == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '0') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 100 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '50') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 50 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', 'non-numeric') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 50 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '101') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 0 == current_percentage_cover_position + assert STATE_CLOSED == self.hass.states.get( + 'cover.test').state + def test_set_cover_position(self): """Test setting cover position.""" assert setup_component(self.hass, cover.DOMAIN, { From 708ababd78dcc68925f3f583eabbc17d48563960 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 21 Nov 2018 19:58:56 +0100 Subject: [PATCH 220/238] Upgrade requests to 2.20.1 (#18615) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7e225cceaee..11f96591705 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ cryptography==2.3.1 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 -requests==2.20.0 +requests==2.20.1 ruamel.yaml==0.15.78 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0672b553eed..e337222d405 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -11,7 +11,7 @@ cryptography==2.3.1 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 -requests==2.20.0 +requests==2.20.1 ruamel.yaml==0.15.78 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/setup.py b/setup.py index 9e24362fe8a..49147afdd70 100755 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ REQUIRES = [ 'pip>=8.0.3', 'pytz>=2018.04', 'pyyaml>=3.13,<4', - 'requests==2.20.0', + 'requests==2.20.1', 'ruamel.yaml==0.15.78', 'voluptuous==0.11.5', 'voluptuous-serialize==2.0.0', From 49121f2347e0d83c6d0e9d4f59678140208d841a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:18:56 +0100 Subject: [PATCH 221/238] Update translations --- .../dialogflow/.translations/de.json | 10 +++ .../dialogflow/.translations/nl.json | 15 +++++ .../homematicip_cloud/.translations/ko.json | 2 +- .../components/hue/.translations/it.json | 2 +- .../luftdaten/.translations/de.json | 17 ++++++ .../luftdaten/.translations/nl.json | 19 ++++++ .../luftdaten/.translations/zh-Hans.json | 19 ++++++ .../components/point/.translations/ca.json | 32 ++++++++++ .../components/point/.translations/en.json | 61 +++++++++---------- .../components/point/.translations/it.json | 12 ++++ .../components/point/.translations/ko.json | 32 ++++++++++ .../components/point/.translations/lb.json | 32 ++++++++++ .../components/point/.translations/ru.json | 28 +++++++++ .../point/.translations/zh-Hans.json | 11 ++++ .../rainmachine/.translations/ca.json | 19 ++++++ .../rainmachine/.translations/de.json | 19 ++++++ .../rainmachine/.translations/nl.json | 19 ++++++ .../rainmachine/.translations/no.json | 19 ++++++ .../rainmachine/.translations/zh-Hans.json | 16 +++++ .../rainmachine/.translations/zh-Hant.json | 19 ++++++ .../components/twilio/.translations/de.json | 5 ++ .../components/twilio/.translations/nl.json | 1 + .../components/unifi/.translations/de.json | 16 +++++ .../components/upnp/.translations/nl.json | 1 + 24 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/dialogflow/.translations/de.json create mode 100644 homeassistant/components/dialogflow/.translations/nl.json create mode 100644 homeassistant/components/luftdaten/.translations/de.json create mode 100644 homeassistant/components/luftdaten/.translations/nl.json create mode 100644 homeassistant/components/luftdaten/.translations/zh-Hans.json create mode 100644 homeassistant/components/point/.translations/ca.json create mode 100644 homeassistant/components/point/.translations/it.json create mode 100644 homeassistant/components/point/.translations/ko.json create mode 100644 homeassistant/components/point/.translations/lb.json create mode 100644 homeassistant/components/point/.translations/ru.json create mode 100644 homeassistant/components/point/.translations/zh-Hans.json create mode 100644 homeassistant/components/rainmachine/.translations/ca.json create mode 100644 homeassistant/components/rainmachine/.translations/de.json create mode 100644 homeassistant/components/rainmachine/.translations/nl.json create mode 100644 homeassistant/components/rainmachine/.translations/no.json create mode 100644 homeassistant/components/rainmachine/.translations/zh-Hans.json create mode 100644 homeassistant/components/rainmachine/.translations/zh-Hant.json create mode 100644 homeassistant/components/twilio/.translations/de.json create mode 100644 homeassistant/components/unifi/.translations/de.json diff --git a/homeassistant/components/dialogflow/.translations/de.json b/homeassistant/components/dialogflow/.translations/de.json new file mode 100644 index 00000000000..e10d890b501 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/de.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Dialogflow Webhook einrichten" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/nl.json b/homeassistant/components/dialogflow/.translations/nl.json new file mode 100644 index 00000000000..5a28d6be9ac --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Dialogflow-berichten te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "step": { + "user": { + "description": "Weet u zeker dat u Dialogflow wilt instellen?", + "title": "Stel de Twilio Dialogflow in" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json index 46ef55c9eca..b60da944f64 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ko.json +++ b/homeassistant/components/homematicip_cloud/.translations/ko.json @@ -21,7 +21,7 @@ "title": "HomematicIP \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc120\ud0dd" }, "link": { - "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc \uc11c\ubc0b \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", "title": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc5d0 \uc5f0\uacb0" } }, diff --git a/homeassistant/components/hue/.translations/it.json b/homeassistant/components/hue/.translations/it.json index a9f2a732127..72b2fd6445b 100644 --- a/homeassistant/components/hue/.translations/it.json +++ b/homeassistant/components/hue/.translations/it.json @@ -17,7 +17,7 @@ "data": { "host": "Host" }, - "title": "Selezione il bridge Hue" + "title": "Seleziona il bridge Hue" }, "link": { "description": "Premi il pulsante sul bridge per registrare Philips Hue con Home Assistant\n\n![Posizione del pulsante sul bridge](/static/images/config_philips_hue.jpg)", diff --git a/homeassistant/components/luftdaten/.translations/de.json b/homeassistant/components/luftdaten/.translations/de.json new file mode 100644 index 00000000000..136b907df81 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "communication_error": "Keine Kommunikation mit Lufdaten API m\u00f6glich", + "invalid_sensor": "Sensor nicht verf\u00fcgbar oder ung\u00fcltig", + "sensor_exists": "Sensor bereits registriert" + }, + "step": { + "user": { + "data": { + "show_on_map": "Auf Karte anzeigen" + } + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/nl.json b/homeassistant/components/luftdaten/.translations/nl.json new file mode 100644 index 00000000000..3284b581f5f --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Kan niet communiceren met de Luftdaten API", + "invalid_sensor": "Sensor niet beschikbaar of ongeldig", + "sensor_exists": "Sensor bestaat al" + }, + "step": { + "user": { + "data": { + "show_on_map": "Toon op kaart", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Definieer Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/zh-Hans.json b/homeassistant/components/luftdaten/.translations/zh-Hans.json new file mode 100644 index 00000000000..375a08d8a45 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "\u65e0\u6cd5\u4e0e Luftdaten API \u901a\u4fe1", + "invalid_sensor": "\u4f20\u611f\u5668\u4e0d\u53ef\u7528\u6216\u65e0\u6548", + "sensor_exists": "\u4f20\u611f\u5668\u5df2\u6ce8\u518c" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u5728\u5730\u56fe\u4e0a\u663e\u793a", + "station_id": "Luftdaten \u4f20\u611f\u5668 ID" + }, + "title": "\u5b9a\u4e49 Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ca.json b/homeassistant/components/point/.translations/ca.json new file mode 100644 index 00000000000..6298b29f268 --- /dev/null +++ b/homeassistant/components/point/.translations/ca.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s podeu configurar un compte de Point.", + "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", + "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", + "external_setup": "Point s'ha configurat correctament des d'un altre lloc.", + "no_flows": "Necessiteu configurar Point abans de poder autenticar-vos-hi. [Llegiu les instruccions](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Minut per als vostres dispositiu/s Point." + }, + "error": { + "follow_link": "Si us plau seguiu l'enlla\u00e7 i autentiqueu-vos abans de pr\u00e9mer Enviar", + "no_token": "No s'ha autenticat amb Minut" + }, + "step": { + "auth": { + "description": "Aneu a l'enlla\u00e7 seg\u00fcent i Accepta l'acc\u00e9s al vostre compte de Minut, despr\u00e9s torneu i premeu Enviar (a sota). \n\n[Enlla\u00e7]({authorization_url})", + "title": "Autenticar Point" + }, + "user": { + "data": { + "flow_impl": "Prove\u00efdor" + }, + "description": "Trieu a trav\u00e9s de quin prove\u00efdor d'autenticaci\u00f3 us voleu autenticar amb Point.", + "title": "Prove\u00efdor d'autenticaci\u00f3" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/en.json b/homeassistant/components/point/.translations/en.json index fed892113c3..705ac59b98d 100644 --- a/homeassistant/components/point/.translations/en.json +++ b/homeassistant/components/point/.translations/en.json @@ -1,33 +1,32 @@ { - "config": { - "title": "Minut Point", - "step": { - "user": { - "title": "Authentication Provider", - "description": "Pick via which authentication provider you want to authenticate with Point.", - "data": { - "flow_impl": "Provider" - } - }, - "auth": { - "title": "Authenticate Point", - "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})" - } - }, - "create_entry": { - "default": "Successfully authenticated with Minut for your Point device(s)" - }, - "error": { - "no_token": "Not authenticated with Minut", - "follow_link": "Please follow the link and authenticate before pressing Submit" - }, - "abort": { - "already_setup": "You can only configure a Point account.", - "external_setup": "Point successfully configured from another flow.", - "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/).", - "authorize_url_timeout": "Timeout generating authorize url.", - "authorize_url_fail": "Unknown error generating an authorize url." + "config": { + "abort": { + "already_setup": "You can only configure a Point account.", + "authorize_url_fail": "Unknown error generating an authorize url.", + "authorize_url_timeout": "Timeout generating authorize url.", + "external_setup": "Point successfully configured from another flow.", + "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Successfully authenticated with Minut for your Point device(s)" + }, + "error": { + "follow_link": "Please follow the link and authenticate before pressing Submit", + "no_token": "Not authenticated with Minut" + }, + "step": { + "auth": { + "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})", + "title": "Authenticate Point" + }, + "user": { + "data": { + "flow_impl": "Provider" + }, + "description": "Pick via which authentication provider you want to authenticate with Point.", + "title": "Authentication Provider" + } + }, + "title": "Minut Point" } - } -} - \ No newline at end of file +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/it.json b/homeassistant/components/point/.translations/it.json new file mode 100644 index 00000000000..00e2cb02358 --- /dev/null +++ b/homeassistant/components/point/.translations/it.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "flow_impl": "Provider" + }, + "title": "Provider di autenticazione" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ko.json b/homeassistant/components/point/.translations/ko.json new file mode 100644 index 00000000000..fcc9a92bd5e --- /dev/null +++ b/homeassistant/components/point/.translations/ko.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Point \uacc4\uc815 \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "external_setup": "Point \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.", + "no_flows": "Point \ub97c \uc778\uc99d\ud558\uae30 \uc804\uc5d0 Point \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/point/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." + }, + "create_entry": { + "default": "Point \uc7a5\uce58\ub294 Minut \ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694", + "no_token": "Minut \ub85c \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4" + }, + "step": { + "auth": { + "description": "\uc544\ub798 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud558\uc5ec Minut \uacc4\uc815\uc5d0 \ub300\ud55c \ub3d9\uc758 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n\n [\ub9c1\ud06c] ( {authorization_url} )", + "title": "Point \uc778\uc99d" + }, + "user": { + "data": { + "flow_impl": "\uacf5\uae09\uc790" + }, + "description": "Point\ub85c \uc778\uc99d\ud558\ub824\ub294 \uc778\uc99d \uacf5\uae09\uc790\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", + "title": "\uc778\uc99d \uacf5\uae09\uc790" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/lb.json b/homeassistant/components/point/.translations/lb.json new file mode 100644 index 00000000000..571f4617215 --- /dev/null +++ b/homeassistant/components/point/.translations/lb.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Point Kont konfigur\u00e9ieren.", + "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", + "external_setup": "Point gouf vun engem anere Floss erfollegr\u00e4ich konfigur\u00e9iert.", + "no_flows": "Dir musst Point konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mat Minut authentifiz\u00e9iert fir \u00e4r Point Apparater" + }, + "error": { + "follow_link": "Follegt w.e.g dem Link an authentifiz\u00e9iert iech ier de op Ofsch\u00e9cken dr\u00e9ckt", + "no_token": "Net mat Minut authentifiz\u00e9iert" + }, + "step": { + "auth": { + "description": "Follegt dem Link \u00ebnnendr\u00ebnner an accept\u00e9iert den Acc\u00e8s zu \u00e4rem Minut Kont , dann kommt zer\u00e9ck heihin an dr\u00e9ck op ofsch\u00e9cken hei \u00ebnnen.\n\n[Link]({authorization_url})", + "title": "Point authentifiz\u00e9ieren" + }, + "user": { + "data": { + "flow_impl": "Ubidder" + }, + "description": "Wielt den Authentifikatioun Ubidder deen sech mat Point verbanne soll.", + "title": "Authentifikatioun Ubidder" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ru.json b/homeassistant/components/point/.translations/ru.json new file mode 100644 index 00000000000..1257e1a7f01 --- /dev/null +++ b/homeassistant/components/point/.translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Point \u043f\u0435\u0440\u0435\u0434 \u0442\u0435\u043c, \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/point/)." + }, + "error": { + "follow_link": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c \u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c", + "no_token": "\u041d\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d \u0432\u0445\u043e\u0434 \u0432 Minut" + }, + "step": { + "auth": { + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Minut, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c.", + "title": "\u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0432 Point" + }, + "user": { + "data": { + "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0435\u0440\u0435\u0437 \u043a\u0430\u043a\u043e\u0433\u043e \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0432\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0432 Point.", + "title": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json new file mode 100644 index 00000000000..7d88bfeec42 --- /dev/null +++ b/homeassistant/components/point/.translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Point \u8fdb\u884c\u6388\u6743\u3002", + "title": "\u6388\u6743\u63d0\u4f9b\u8005" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ca.json b/homeassistant/components/rainmachine/.translations/ca.json new file mode 100644 index 00000000000..7a1459cff6b --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Aquest compte ja est\u00e0 registrat", + "invalid_credentials": "Credencials inv\u00e0lides" + }, + "step": { + "user": { + "data": { + "ip_address": "Nom de l'amfitri\u00f3 o adre\u00e7a IP", + "password": "Contrasenya", + "port": "Port" + }, + "title": "Introdu\u00efu la vostra informaci\u00f3" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/de.json b/homeassistant/components/rainmachine/.translations/de.json new file mode 100644 index 00000000000..c262fa5a652 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto bereits registriert", + "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" + }, + "step": { + "user": { + "data": { + "ip_address": "Hostname oder IP-Adresse", + "password": "Passwort", + "port": "Port" + }, + "title": "Informationen eingeben" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/nl.json b/homeassistant/components/rainmachine/.translations/nl.json new file mode 100644 index 00000000000..2e1e62c683c --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account bestaat al", + "invalid_credentials": "Ongeldige gebruikersgegevens" + }, + "step": { + "user": { + "data": { + "ip_address": "Hostnaam of IP-adres", + "password": "Wachtwoord", + "port": "Poort" + }, + "title": "Vul uw gegevens in" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/no.json b/homeassistant/components/rainmachine/.translations/no.json new file mode 100644 index 00000000000..5ec4e5fdc34 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto er allerede registrert", + "invalid_credentials": "Ugyldig legitimasjon" + }, + "step": { + "user": { + "data": { + "ip_address": "Vertsnavn eller IP-adresse", + "password": "Passord", + "port": "Port" + }, + "title": "Fyll ut informasjonen din" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/zh-Hans.json b/homeassistant/components/rainmachine/.translations/zh-Hans.json new file mode 100644 index 00000000000..7c6f07a7edd --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5e10\u6237\u5df2\u6ce8\u518c" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3" + }, + "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/zh-Hant.json b/homeassistant/components/rainmachine/.translations/zh-Hant.json new file mode 100644 index 00000000000..518cc54192f --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", + "invalid_credentials": "\u6191\u8b49\u7121\u6548" + }, + "step": { + "user": { + "data": { + "ip_address": "\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0" + }, + "title": "\u586b\u5beb\u8cc7\u8a0a" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/de.json b/homeassistant/components/twilio/.translations/de.json new file mode 100644 index 00000000000..86e5d9051b3 --- /dev/null +++ b/homeassistant/components/twilio/.translations/de.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/nl.json b/homeassistant/components/twilio/.translations/nl.json index a053bf372a5..fc8b5c08261 100644 --- a/homeassistant/components/twilio/.translations/nl.json +++ b/homeassistant/components/twilio/.translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Twillo-berichten te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n exemplaar is nodig." }, "step": { diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json new file mode 100644 index 00000000000..346c1937355 --- /dev/null +++ b/homeassistant/components/unifi/.translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "user_privilege": "Der Benutzer muss Administrator sein" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/nl.json b/homeassistant/components/upnp/.translations/nl.json index 647eb647f24..c6939f9a0a7 100644 --- a/homeassistant/components/upnp/.translations/nl.json +++ b/homeassistant/components/upnp/.translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP / IGD is al geconfigureerd", + "incomplete_device": "Onvolledig UPnP-apparaat negeren", "no_devices_discovered": "Geen UPnP / IGD's ontdekt", "no_sensors_or_port_mapping": "Schakel ten minste sensoren of poorttoewijzing in" }, From 4e58eb8baeb15b91ff80725c4d0a793a2d8ae655 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:35:46 +0100 Subject: [PATCH 222/238] Updated frontend to 20181121.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 2f723af2b13..3768a59788e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181112.0'] +REQUIREMENTS = ['home-assistant-frontend==20181121.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index e337222d405..4ddc81686b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -485,7 +485,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181112.0 +home-assistant-frontend==20181121.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ed7510cae0..6ebc180908e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181112.0 +home-assistant-frontend==20181121.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From fd7fff2ce8d759db9eaa74c4823622471691a0a6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:50:11 +0100 Subject: [PATCH 223/238] Version bump to 0.83.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 72fc2165d28..29e01faaa48 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 6bcedb3ac5adcf7ff39ff27f034cc2ee6c35a6c4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:16:30 +0100 Subject: [PATCH 224/238] Updated frontend to 20181121.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 3768a59788e..d8ea057a4f0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181121.0'] +REQUIREMENTS = ['home-assistant-frontend==20181121.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 4ddc81686b4..8072940ddbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -485,7 +485,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.0 +home-assistant-frontend==20181121.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ebc180908e..f7223771891 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.0 +home-assistant-frontend==20181121.1 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From cf22060c5ed5c7a7fe52c612042f9ed89bedd38b Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Mon, 26 Nov 2018 13:17:56 +0100 Subject: [PATCH 225/238] Use asyncio Lock for fibaro light (#18622) * Use asyncio Lock for fibaro light * line length and empty line at end * async turn_off Turned the turn_off into async as well * bless you, blank lines... My local flake8 lies to me. Not cool. --- homeassistant/components/light/fibaro.py | 144 +++++++++++++---------- 1 file changed, 81 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index cfc28e12218..96069d50335 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/light.fibaro/ """ import logging -import threading +import asyncio +from functools import partial from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT, @@ -37,12 +38,15 @@ def scaleto100(value): return max(0, min(100, ((value * 100.4) / 255.0))) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): """Perform the setup for Fibaro controller devices.""" if discovery_info is None: return - add_entities( + async_add_entities( [FibaroLight(device, hass.data[FIBARO_CONTROLLER]) for device in hass.data[FIBARO_DEVICES]['light']], True) @@ -58,7 +62,7 @@ class FibaroLight(FibaroDevice, Light): self._brightness = None self._white = 0 - self._update_lock = threading.RLock() + self._update_lock = asyncio.Lock() if 'levelChange' in fibaro_device.interfaces: self._supported_flags |= SUPPORT_BRIGHTNESS if 'color' in fibaro_device.properties: @@ -88,78 +92,92 @@ class FibaroLight(FibaroDevice, Light): """Flag supported features.""" return self._supported_flags - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" - with self._update_lock: - if self._supported_flags & SUPPORT_BRIGHTNESS: - target_brightness = kwargs.get(ATTR_BRIGHTNESS) + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_on, **kwargs)) - # No brightness specified, so we either restore it to - # last brightness or switch it on at maximum level - if target_brightness is None: - if self._brightness == 0: - if self._last_brightness: - self._brightness = self._last_brightness - else: - self._brightness = 100 - else: - # We set it to the target brightness and turn it on - self._brightness = scaleto100(target_brightness) + def _turn_on(self, **kwargs): + """Really turn the light on.""" + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_BRIGHTNESS) - if self._supported_flags & SUPPORT_COLOR: - # Update based on parameters - self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) - self._color = kwargs.get(ATTR_HS_COLOR, self._color) - rgb = color_util.color_hs_to_RGB(*self._color) - self.call_set_color( - int(rgb[0] * self._brightness / 99.0 + 0.5), - int(rgb[1] * self._brightness / 99.0 + 0.5), - int(rgb[2] * self._brightness / 99.0 + 0.5), - int(self._white * self._brightness / 99.0 + - 0.5)) - if self.state == 'off': - self.set_level(int(self._brightness)) - return + # No brightness specified, so we either restore it to + # last brightness or switch it on at maximum level + if target_brightness is None: + if self._brightness == 0: + if self._last_brightness: + self._brightness = self._last_brightness + else: + self._brightness = 100 + else: + # We set it to the target brightness and turn it on + self._brightness = scaleto100(target_brightness) - if self._supported_flags & SUPPORT_BRIGHTNESS: + if self._supported_flags & SUPPORT_COLOR: + # Update based on parameters + self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) + self._color = kwargs.get(ATTR_HS_COLOR, self._color) + rgb = color_util.color_hs_to_RGB(*self._color) + self.call_set_color( + int(rgb[0] * self._brightness / 99.0 + 0.5), + int(rgb[1] * self._brightness / 99.0 + 0.5), + int(rgb[2] * self._brightness / 99.0 + 0.5), + int(self._white * self._brightness / 99.0 + + 0.5)) + if self.state == 'off': self.set_level(int(self._brightness)) - return + return - # The simplest case is left for last. No dimming, just switch on - self.call_turn_on() + if self._supported_flags & SUPPORT_BRIGHTNESS: + self.set_level(int(self._brightness)) + return - def turn_off(self, **kwargs): + # The simplest case is left for last. No dimming, just switch on + self.call_turn_on() + + async def async_turn_off(self, **kwargs): """Turn the light off.""" + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_off, **kwargs)) + + def _turn_off(self, **kwargs): + """Really turn the light off.""" # Let's save the last brightness level before we switch it off - with self._update_lock: - if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ - self._brightness and self._brightness > 0: - self._last_brightness = self._brightness - self._brightness = 0 - self.call_turn_off() + if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ + self._brightness and self._brightness > 0: + self._last_brightness = self._brightness + self._brightness = 0 + self.call_turn_off() @property def is_on(self): """Return true if device is on.""" return self.current_binary_state - def update(self): - """Call to update state.""" + async def async_update(self): + """Update the state.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update) + + def _update(self): + """Really update the state.""" # Brightness handling - with self._update_lock: - if self._supported_flags & SUPPORT_BRIGHTNESS: - self._brightness = float(self.fibaro_device.properties.value) - # Color handling - if self._supported_flags & SUPPORT_COLOR: - # Fibaro communicates the color as an 'R, G, B, W' string - rgbw_s = self.fibaro_device.properties.color - if rgbw_s == '0,0,0,0' and\ - 'lastColorSet' in self.fibaro_device.properties: - rgbw_s = self.fibaro_device.properties.lastColorSet - rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] - if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: - self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) - if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ - self.brightness != 0: - self._white = min(255, max(0, rgbw_list[3]*100.0 / - self._brightness)) + if self._supported_flags & SUPPORT_BRIGHTNESS: + self._brightness = float(self.fibaro_device.properties.value) + # Color handling + if self._supported_flags & SUPPORT_COLOR: + # Fibaro communicates the color as an 'R, G, B, W' string + rgbw_s = self.fibaro_device.properties.color + if rgbw_s == '0,0,0,0' and\ + 'lastColorSet' in self.fibaro_device.properties: + rgbw_s = self.fibaro_device.properties.lastColorSet + rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] + if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: + self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) + if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ + self.brightness != 0: + self._white = min(255, max(0, rgbw_list[3]*100.0 / + self._brightness)) From 2f581b1a1ea17a69db0c20223a9527c503dbeab1 Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Fri, 23 Nov 2018 01:46:22 +0100 Subject: [PATCH 226/238] fixed wording that may confuse user (#18628) --- homeassistant/components/recorder/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a6a6ed46174..825f402aef2 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -29,7 +29,7 @@ def migrate_schema(instance): with open(progress_path, 'w'): pass - _LOGGER.warning("Database requires upgrade. Schema version: %s", + _LOGGER.warning("Database is about to upgrade. Schema version: %s", current_version) if current_version is None: From bb75a39cf165a88bd2cd72d4289408c53685f85e Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 22 Nov 2018 16:43:10 +0100 Subject: [PATCH 227/238] Updated webhook_register, version bump pypoint (#18635) * Updated webhook_register, version bump pypoint * A binary_sensor should be a BinarySensorDevice --- homeassistant/components/binary_sensor/point.py | 3 ++- homeassistant/components/point/__init__.py | 6 +++--- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py index a2ed9eabebf..90a8b0b5813 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/binary_sensor.point/ import logging +from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.point import MinutPointEntity from homeassistant.components.point.const import ( DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) @@ -45,7 +46,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device_class in EVENTS), True) -class MinutPointBinarySensor(MinutPointEntity): +class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice): """The platform class required by Home Assistant.""" def __init__(self, point_client, device_id, device_class): diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index fcbd5ddb064..36215da7893 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -25,7 +25,7 @@ from .const import ( CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) -REQUIREMENTS = ['pypoint==1.0.5'] +REQUIREMENTS = ['pypoint==1.0.6'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) @@ -113,8 +113,8 @@ async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, session.update_webhook(entry.data[CONF_WEBHOOK_URL], entry.data[CONF_WEBHOOK_ID]) - hass.components.webhook.async_register(entry.data[CONF_WEBHOOK_ID], - handle_webhook) + hass.components.webhook.async_register( + DOMAIN, 'Point', entry.data[CONF_WEBHOOK_ID], handle_webhook) async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): diff --git a/requirements_all.txt b/requirements_all.txt index 8072940ddbd..bc53dbce24e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1099,7 +1099,7 @@ pyowm==2.9.0 pypjlink2==1.2.0 # homeassistant.components.point -pypoint==1.0.5 +pypoint==1.0.6 # homeassistant.components.sensor.pollen pypollencom==2.2.2 From 56c7c8ccc514e8b26c3dc443137acc234fbf9319 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 22 Nov 2018 12:48:50 +0100 Subject: [PATCH 228/238] Fix vol Dict -> dict (#18637) --- homeassistant/components/lovelace/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 39644bd047b..5234dbaf29d 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -63,7 +63,7 @@ SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_CARD, vol.Required('card_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) @@ -71,7 +71,7 @@ SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_ADD_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_CARD, vol.Required('view_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), @@ -99,14 +99,14 @@ SCHEMA_GET_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_UPDATE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_VIEW, vol.Required('view_id'): str, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) SCHEMA_ADD_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_VIEW, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), From c3b76b40f6b4fa4433bde27472a370412053f24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 25 Nov 2018 12:30:38 +0100 Subject: [PATCH 229/238] Set correct default offset (#18678) --- homeassistant/components/sensor/ruter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/ruter.py b/homeassistant/components/sensor/ruter.py index ddad6a43c75..7b02b51d0c0 100644 --- a/homeassistant/components/sensor/ruter.py +++ b/homeassistant/components/sensor/ruter.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STOP_ID): cv.positive_int, vol.Optional(CONF_DESTINATION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=1): cv.positive_int, + vol.Optional(CONF_OFFSET, default=0): cv.positive_int, }) From f9f71c4a6dc7dfdfb156a2f4f96970cb895262e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:20:56 +0100 Subject: [PATCH 230/238] Bumped version to 0.83.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 29e01faaa48..9866bb5dad9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 3a8303137a6151c74fc6f8236f7f7f3f9766b2f2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 25 Nov 2018 18:04:48 +0100 Subject: [PATCH 231/238] Add permission checks to Rest API (#18639) * Add permission checks to Rest API * Clean up unnecessary method * Remove all the tuple stuff from entity check * Simplify perms * Correct param name for owner permission * Hass.io make/update user to be admin * Types --- homeassistant/auth/__init__.py | 17 +++- homeassistant/auth/auth_store.py | 27 +++++++ homeassistant/auth/models.py | 16 +++- homeassistant/auth/permissions/__init__.py | 61 +++++---------- homeassistant/auth/permissions/entities.py | 40 +++++----- homeassistant/components/api.py | 27 ++++++- homeassistant/components/hassio/__init__.py | 9 ++- homeassistant/components/http/view.py | 10 ++- homeassistant/helpers/service.py | 10 +-- tests/auth/permissions/test_entities.py | 50 ++++++------ tests/auth/permissions/test_init.py | 34 -------- tests/common.py | 7 +- tests/components/conftest.py | 5 +- tests/components/hassio/test_init.py | 28 +++++++ tests/components/test_api.py | 86 +++++++++++++++++++-- 15 files changed, 282 insertions(+), 145 deletions(-) delete mode 100644 tests/auth/permissions/test_init.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e69dec37df2..7d8ef13d2bb 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -132,13 +132,15 @@ class AuthManager: return None - async def async_create_system_user(self, name: str) -> models.User: + async def async_create_system_user( + self, name: str, + group_ids: Optional[List[str]] = None) -> models.User: """Create a system user.""" user = await self._store.async_create_user( name=name, system_generated=True, is_active=True, - group_ids=[], + group_ids=group_ids or [], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { @@ -217,6 +219,17 @@ class AuthManager: 'user_id': user.id }) + async def async_update_user(self, user: models.User, + name: Optional[str] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + kwargs = {} # type: Dict[str,Any] + if name is not None: + kwargs['name'] = name + if group_ids is not None: + kwargs['group_ids'] = group_ids + await self._store.async_update_user(user, **kwargs) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 867d5357a58..cf82c40a4d3 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -133,6 +133,33 @@ class AuthStore: self._users.pop(user.id) self._async_schedule_save() + async def async_update_user( + self, user: models.User, name: Optional[str] = None, + is_active: Optional[bool] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + assert self._groups is not None + + if group_ids is not None: + groups = [] + for grid in group_ids: + group = self._groups.get(grid) + if group is None: + raise ValueError("Invalid group specified.") + groups.append(group) + + user.groups = groups + user.invalidate_permission_cache() + + for attr_name, value in ( + ('name', name), + ('is_active', is_active), + ): + if value is not None: + setattr(user, attr_name, value) + + self._async_schedule_save() + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" user.is_active = True diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index cefaabe7521..4b192c35898 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -8,6 +8,7 @@ import attr from homeassistant.util import dt as dt_util from . import permissions as perm_mdl +from .const import GROUP_ID_ADMIN from .util import generate_secret TOKEN_TYPE_NORMAL = 'normal' @@ -48,7 +49,7 @@ class User: ) # type: Dict[str, RefreshToken] _permissions = attr.ib( - type=perm_mdl.PolicyPermissions, + type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None, @@ -69,6 +70,19 @@ class User: return self._permissions + @property + def is_admin(self) -> bool: + """Return if user is part of the admin group.""" + if self.is_owner: + return True + + return self.is_active and any( + gr.id == GROUP_ID_ADMIN for gr in self.groups) + + def invalidate_permission_cache(self) -> None: + """Invalidate permission cache.""" + self._permissions = None + @attr.s(slots=True) class RefreshToken: diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index fd3cf81f029..9113f2b03a9 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -5,10 +5,8 @@ from typing import ( # noqa: F401 import voluptuous as vol -from homeassistant.core import State - from .const import CAT_ENTITIES -from .types import CategoryType, PolicyType +from .types import PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa @@ -22,13 +20,20 @@ _LOGGER = logging.getLogger(__name__) class AbstractPermissions: """Default permissions class.""" - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" + _cached_entity_func = None + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" raise NotImplementedError - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - raise NotImplementedError + def check_entity(self, entity_id: str, key: str) -> bool: + """Check if we can access entity.""" + entity_func = self._cached_entity_func + + if entity_func is None: + entity_func = self._cached_entity_func = self._entity_func() + + return entity_func(entity_id, key) class PolicyPermissions(AbstractPermissions): @@ -37,34 +42,10 @@ class PolicyPermissions(AbstractPermissions): def __init__(self, policy: PolicyType) -> None: """Initialize the permission class.""" self._policy = policy - self._compiled = {} # type: Dict[str, Callable[..., bool]] - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - return func(entity_id, (key,)) - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - keys = ('read',) - return [entity for entity in states if func(entity.entity_id, keys)] - - def _policy_func(self, category: str, - compile_func: Callable[[CategoryType], Callable]) \ - -> Callable[..., bool]: - """Get a policy function.""" - func = self._compiled.get(category) - - if func: - return func - - func = self._compiled[category] = compile_func( - self._policy.get(category)) - - _LOGGER.debug("Compiled %s func: %s", category, func) - - return func + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return compile_entities(self._policy.get(CAT_ENTITIES)) def __eq__(self, other: Any) -> bool: """Equals check.""" @@ -78,13 +59,9 @@ class _OwnerPermissions(AbstractPermissions): # pylint: disable=no-self-use - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - return True - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - return states + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return lambda entity_id, key: True OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 89b9398628c..74a43246fd1 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -28,28 +28,28 @@ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ })) -def _entity_allowed(schema: ValueType, keys: Tuple[str]) \ +def _entity_allowed(schema: ValueType, key: str) \ -> Union[bool, None]: """Test if an entity is allowed based on the keys.""" if schema is None or isinstance(schema, bool): return schema assert isinstance(schema, dict) - return schema.get(keys[0]) + return schema.get(key) def compile_entities(policy: CategoryType) \ - -> Callable[[str, Tuple[str]], bool]: + -> Callable[[str, str], bool]: """Compile policy into a function that tests policy.""" # None, Empty Dict, False if not policy: - def apply_policy_deny_all(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_deny_all(entity_id: str, key: str) -> bool: """Decline all.""" return False return apply_policy_deny_all if policy is True: - def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: """Approve all.""" return True @@ -61,7 +61,7 @@ def compile_entities(policy: CategoryType) \ entity_ids = policy.get(ENTITY_ENTITY_IDS) all_entities = policy.get(SUBCAT_ALL) - funcs = [] # type: List[Callable[[str, Tuple[str]], Union[None, bool]]] + funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] # The order of these functions matter. The more precise are at the top. # If a function returns None, they cannot handle it. @@ -70,23 +70,23 @@ def compile_entities(policy: CategoryType) \ # Setting entity_ids to a boolean is final decision for permissions # So return right away. if isinstance(entity_ids, bool): - def allowed_entity_id_bool(entity_id: str, keys: Tuple[str]) -> bool: + def allowed_entity_id_bool(entity_id: str, key: str) -> bool: """Test if allowed entity_id.""" return entity_ids # type: ignore return allowed_entity_id_bool if entity_ids is not None: - def allowed_entity_id_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_entity_id_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed entity_id.""" return _entity_allowed( - entity_ids.get(entity_id), keys) # type: ignore + entity_ids.get(entity_id), key) # type: ignore funcs.append(allowed_entity_id_dict) if isinstance(domains, bool): - def allowed_domain_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return domains @@ -94,31 +94,31 @@ def compile_entities(policy: CategoryType) \ funcs.append(allowed_domain_bool) elif domains is not None: - def allowed_domain_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" domain = entity_id.split(".", 1)[0] - return _entity_allowed(domains.get(domain), keys) # type: ignore + return _entity_allowed(domains.get(domain), key) # type: ignore funcs.append(allowed_domain_dict) if isinstance(all_entities, bool): - def allowed_all_entities_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_all_entities_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return all_entities funcs.append(allowed_all_entities_bool) elif all_entities is not None: - def allowed_all_entities_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_all_entities_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" - return _entity_allowed(all_entities, keys) + return _entity_allowed(all_entities, key) funcs.append(allowed_all_entities_dict) # Can happen if no valid subcategories specified if not funcs: - def apply_policy_deny_all_2(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_deny_all_2(entity_id: str, key: str) -> bool: """Decline all.""" return False @@ -128,16 +128,16 @@ def compile_entities(policy: CategoryType) \ func = funcs[0] @wraps(func) - def apply_policy_func(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_func(entity_id: str, key: str) -> bool: """Apply a single policy function.""" - return func(entity_id, keys) is True + return func(entity_id, key) is True return apply_policy_func - def apply_policy_funcs(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_funcs(entity_id: str, key: str) -> bool: """Apply several policy functions.""" for func in funcs: - result = func(entity_id, keys) + result = func(entity_id, key) if result is not None: return result return False diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index cbe404537eb..b001bcd0437 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -20,7 +20,8 @@ from homeassistant.const import ( URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, __version__) import homeassistant.core as ha -from homeassistant.exceptions import TemplateError +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import template from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.state import AsyncTrackStates @@ -81,6 +82,8 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" + if not request['hass_user'].is_admin: + raise Unauthorized() hass = request.app['hass'] stop_obj = object() to_write = asyncio.Queue(loop=hass.loop) @@ -185,7 +188,13 @@ class APIStatesView(HomeAssistantView): @ha.callback def get(self, request): """Get current states.""" - return self.json(request.app['hass'].states.async_all()) + user = request['hass_user'] + entity_perm = user.permissions.check_entity + states = [ + state for state in request.app['hass'].states.async_all() + if entity_perm(state.entity_id, 'read') + ] + return self.json(states) class APIEntityStateView(HomeAssistantView): @@ -197,6 +206,10 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def get(self, request, entity_id): """Retrieve state of entity.""" + user = request['hass_user'] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + state = request.app['hass'].states.get(entity_id) if state: return self.json(state) @@ -204,6 +217,8 @@ class APIEntityStateView(HomeAssistantView): async def post(self, request, entity_id): """Update state of entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) hass = request.app['hass'] try: data = await request.json() @@ -236,6 +251,8 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def delete(self, request, entity_id): """Remove entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) if request.app['hass'].states.async_remove(entity_id): return self.json_message("Entity removed.") return self.json_message("Entity not found.", HTTP_NOT_FOUND) @@ -261,6 +278,8 @@ class APIEventView(HomeAssistantView): async def post(self, request, event_type): """Fire events.""" + if not request['hass_user'].is_admin: + raise Unauthorized() body = await request.text() try: event_data = json.loads(body) if body else None @@ -346,6 +365,8 @@ class APITemplateView(HomeAssistantView): async def post(self, request): """Render a template.""" + if not request['hass_user'].is_admin: + raise Unauthorized() try: data = await request.json() tpl = template.Template(data['template'], request.app['hass']) @@ -363,6 +384,8 @@ class APIErrorLog(HomeAssistantView): async def get(self, request): """Retrieve API error log.""" + if not request['hass_user'].is_admin: + raise Unauthorized() return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4c13cb799a6..6bfcaaa5d85 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -10,6 +10,7 @@ import os import voluptuous as vol +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.const import ( ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) @@ -181,8 +182,14 @@ async def async_setup(hass, config): if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] + # Migrate old hass.io users to be admin. + if not user.is_admin: + await hass.auth.async_update_user( + user, group_ids=[GROUP_ID_ADMIN]) + if refresh_token is None: - user = await hass.auth.async_create_system_user('Hass.io') + user = await hass.auth.async_create_system_user( + 'Hass.io', [GROUP_ID_ADMIN]) refresh_token = await hass.auth.async_create_refresh_token(user) data['hassio_user'] = user.id await store.async_save(data) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index b3b2587fc45..30d4ed0ab8d 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -14,6 +14,7 @@ from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError from homeassistant.components.http.ban import process_success_login from homeassistant.core import Context, is_callback from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant import exceptions from homeassistant.helpers.json import JSONEncoder from .const import KEY_AUTHENTICATED, KEY_REAL_IP @@ -107,10 +108,13 @@ def request_handler_factory(view, handler): _LOGGER.info('Serving %s to %s (auth: %s)', request.path, request.get(KEY_REAL_IP), authenticated) - result = handler(request, **request.match_info) + try: + result = handler(request, **request.match_info) - if asyncio.iscoroutine(result): - result = await result + if asyncio.iscoroutine(result): + result = await result + except exceptions.Unauthorized: + raise HTTPUnauthorized() if isinstance(result, web.StreamResponse): # The method handler returned a ready-made Response, how nice of it diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 5e0d9c7e88a..e8068f57286 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -192,9 +192,9 @@ async def entity_service_call(hass, platforms, func, call): user = await hass.auth.async_get_user(call.context.user_id) if user is None: raise UnknownUser(context=call.context) - perms = user.permissions + entity_perms = user.permissions.check_entity else: - perms = None + entity_perms = None # Are we trying to target all entities target_all_entities = ATTR_ENTITY_ID not in call.data @@ -218,7 +218,7 @@ async def entity_service_call(hass, platforms, func, call): # the service on. platforms_entities = [] - if perms is None: + if entity_perms is None: for platform in platforms: if target_all_entities: platforms_entities.append(list(platform.entities.values())) @@ -234,7 +234,7 @@ async def entity_service_call(hass, platforms, func, call): for platform in platforms: platforms_entities.append([ entity for entity in platform.entities.values() - if perms.check_entity(entity.entity_id, POLICY_CONTROL)]) + if entity_perms(entity.entity_id, POLICY_CONTROL)]) else: for platform in platforms: @@ -243,7 +243,7 @@ async def entity_service_call(hass, platforms, func, call): if entity.entity_id not in entity_ids: continue - if not perms.check_entity(entity.entity_id, POLICY_CONTROL): + if not entity_perms(entity.entity_id, POLICY_CONTROL): raise Unauthorized( context=call.context, entity_id=entity.entity_id, diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 33c164d12b4..40de5ca7334 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -10,7 +10,7 @@ def test_entities_none(): """Test entity ID policy.""" policy = None compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_empty(): @@ -18,7 +18,7 @@ def test_entities_empty(): policy = {} ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_false(): @@ -33,7 +33,7 @@ def test_entities_true(): policy = True ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_true(): @@ -43,7 +43,7 @@ def test_entities_domains_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_domain_true(): @@ -55,8 +55,8 @@ def test_entities_domains_domain_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_domains_domain_false(): @@ -77,7 +77,7 @@ def test_entities_entity_ids_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_entity_ids_false(): @@ -98,8 +98,8 @@ def test_entities_entity_ids_entity_id_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_entity_ids_entity_id_false(): @@ -124,9 +124,9 @@ def test_entities_control_only(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('light.kitchen', 'edit') is False def test_entities_read_control(): @@ -141,9 +141,9 @@ def test_entities_read_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('light.kitchen', 'edit') is False def test_entities_all_allow(): @@ -153,9 +153,9 @@ def test_entities_all_allow(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is True def test_entities_all_read(): @@ -167,9 +167,9 @@ def test_entities_all_read(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('switch.kitchen', 'read') is True def test_entities_all_control(): @@ -181,7 +181,7 @@ def test_entities_all_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is False - assert compiled('switch.kitchen', ('control',)) is True + assert compiled('light.kitchen', 'read') is False + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is False + assert compiled('switch.kitchen', 'control') is True diff --git a/tests/auth/permissions/test_init.py b/tests/auth/permissions/test_init.py deleted file mode 100644 index fdc5440a9d5..00000000000 --- a/tests/auth/permissions/test_init.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Tests for the auth permission system.""" -from homeassistant.core import State -from homeassistant.auth import permissions - - -def test_policy_perm_filter_states(): - """Test filtering entitites.""" - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - perm = permissions.PolicyPermissions({ - 'entities': { - 'entity_ids': { - 'light.kitchen': True, - 'light.balcony': True, - } - } - }) - filtered = perm.filter_states(states) - assert len(filtered) == 2 - assert filtered == [states[0], states[2]] - - -def test_owner_permissions(): - """Test owner permissions access all.""" - assert permissions.OwnerPermissions.check_entity('light.kitchen', 'write') - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - assert permissions.OwnerPermissions.filter_states(states) == states diff --git a/tests/common.py b/tests/common.py index c6a75fcb63d..d5056e220f0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,7 +14,8 @@ from contextlib import contextmanager from homeassistant import auth, core as ha, config_entries from homeassistant.auth import ( - models as auth_models, auth_store, providers as auth_providers) + models as auth_models, auth_store, providers as auth_providers, + permissions as auth_permissions) from homeassistant.auth.permissions import system_policies from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config @@ -400,6 +401,10 @@ class MockUser(auth_models.User): auth_mgr._store._users[self.id] = self return self + def mock_policy(self, policy): + """Mock a policy for a user.""" + self._permissions = auth_permissions.PolicyPermissions(policy) + async def register_auth_provider(hass, config): """Register an auth provider.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 2568a109244..97f2044baea 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -72,11 +72,10 @@ def hass_ws_client(aiohttp_client): @pytest.fixture -def hass_access_token(hass): +def hass_access_token(hass, hass_admin_user): """Return an access token to access Home Assistant.""" - user = MockUser().add_to_hass(hass) refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(user, CLIENT_ID)) + hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 4fd59dd3f7a..51fca931faa 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch, Mock import pytest +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.setup import async_setup_component from homeassistant.components.hassio import ( STORAGE_KEY, async_check_config) @@ -106,6 +107,8 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, ) assert hassio_user is not None assert hassio_user.system_generated + assert len(hassio_user.groups) == 1 + assert hassio_user.groups[0].id == GROUP_ID_ADMIN for token in hassio_user.refresh_tokens.values(): if token.token == refresh_token: break @@ -113,6 +116,31 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, assert False, 'refresh token not found' +async def test_setup_adds_admin_group_to_user(hass, aioclient_mock, + hass_storage): + """Test setup with API push default data.""" + # Create user without admin + user = await hass.auth.async_create_system_user('Hass.io') + assert not user.is_admin + await hass.auth.async_create_refresh_token(user) + + hass_storage[STORAGE_KEY] = { + 'data': {'hassio_user': user.id}, + 'key': STORAGE_KEY, + 'version': 1 + } + + with patch.dict(os.environ, MOCK_ENVIRON), \ + patch('homeassistant.auth.AuthManager.active', return_value=True): + result = await async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert user.is_admin + + async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, hass_storage): """Test setup with API push default data.""" diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 6f6b4e93068..3ebfa05a3d3 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,10 +16,12 @@ from tests.common import async_mock_service @pytest.fixture -def mock_api_client(hass, aiohttp_client): - """Start the Hass HTTP component.""" +def mock_api_client(hass, aiohttp_client, hass_access_token): + """Start the Hass HTTP component and return admin API client.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + })) @asyncio.coroutine @@ -405,7 +407,8 @@ def _listen_count(hass): return sum(hass.bus.async_listeners().values()) -async def test_api_error_log(hass, aiohttp_client): +async def test_api_error_log(hass, aiohttp_client, hass_access_token, + hass_admin_user): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' await async_setup_component(hass, 'api', { @@ -416,7 +419,7 @@ async def test_api_error_log(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) resp = await client.get(const.URL_API_ERROR_LOG) - # Verufy auth required + # Verify auth required assert resp.status == 401 with patch( @@ -424,7 +427,7 @@ async def test_api_error_log(hass, aiohttp_client): return_value=web.Response(status=200, text='Hello') ) as mock_file: resp = await client.get(const.URL_API_ERROR_LOG, headers={ - 'x-ha-access': 'yolo' + 'Authorization': 'Bearer {}'.format(hass_access_token) }) assert len(mock_file.mock_calls) == 1 @@ -432,6 +435,13 @@ async def test_api_error_log(hass, aiohttp_client): assert resp.status == 200 assert await resp.text() == 'Hello' + # Verify we require admin user + hass_admin_user.groups = [] + resp = await client.get(const.URL_API_ERROR_LOG, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + }) + assert resp.status == 401 + async def test_api_fire_event_context(hass, mock_api_client, hass_access_token): @@ -494,3 +504,67 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token): state = hass.states.get('light.kitchen') assert state.context.user_id == refresh_token.user.id + + +async def test_event_stream_requires_admin(hass, mock_api_client, + hass_admin_user): + """Test user needs to be admin to access event stream.""" + hass_admin_user.groups = [] + resp = await mock_api_client.get('/api/stream') + assert resp.status == 401 + + +async def test_states_view_filters(hass, mock_api_client, hass_admin_user): + """Test filtering only visible states.""" + hass_admin_user.mock_policy({ + 'entities': { + 'entity_ids': { + 'test.entity': True + } + } + }) + hass.states.async_set('test.entity', 'hello') + hass.states.async_set('test.not_visible_entity', 'invisible') + resp = await mock_api_client.get(const.URL_API_STATES) + assert resp.status == 200 + json = await resp.json() + assert len(json) == 1 + assert json[0]['entity_id'] == 'test.entity' + + +async def test_get_entity_state_read_perm(hass, mock_api_client, + hass_admin_user): + """Test getting a state requires read permission.""" + hass_admin_user.mock_policy({}) + resp = await mock_api_client.get('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_entity_state_admin(hass, mock_api_client, hass_admin_user): + """Test updating state requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/states/light.test') + assert resp.status == 401 + + +async def test_delete_entity_state_admin(hass, mock_api_client, + hass_admin_user): + """Test deleting entity requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.delete('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_event_admin(hass, mock_api_client, hass_admin_user): + """Test sending event requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/events/state_changed') + assert resp.status == 401 + + +async def test_rendering_template_admin(hass, mock_api_client, + hass_admin_user): + """Test rendering a template requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/template') + assert resp.status == 401 From 775c909a8c80442280f5b78efc21655bd95a672c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Nov 2018 20:15:57 +0100 Subject: [PATCH 232/238] Bumped version to 0.83.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9866bb5dad9..9fc6d61cb33 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From f3047b9c031cd5b6e373d0639d613cb5de2d5fe5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 19:53:24 +0100 Subject: [PATCH 233/238] Fix logbook filtering entities (#18721) * Fix logbook filtering entities * Fix flaky test --- homeassistant/components/logbook.py | 6 +++--- tests/components/test_logbook.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index ada8bf78ab0..c7a37411f1e 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -391,9 +391,9 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ .filter((Events.time_fired > start_day) & (Events.time_fired < end_day)) \ - .filter((States.last_updated == States.last_changed) - | (States.state_id.is_(None))) \ - .filter(States.entity_id.in_(entity_ids)) + .filter(((States.last_updated == States.last_changed) & + States.entity_id.in_(entity_ids)) + | (States.state_id.is_(None))) events = execute(query) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 5229d34b74c..ae1e3d1d51a 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -62,6 +62,12 @@ class TestComponentLogbook(unittest.TestCase): # Our service call will unblock when the event listeners have been # scheduled. This means that they may not have been processed yet. self.hass.block_till_done() + self.hass.data[recorder.DATA_INSTANCE].block_till_done() + + events = list(logbook._get_events( + self.hass, {}, dt_util.utcnow() - timedelta(hours=1), + dt_util.utcnow() + timedelta(hours=1))) + assert len(events) == 2 assert 1 == len(calls) last_call = calls[-1] From 58e0ff0b1b4a4a1177a837f8b9a70831d17ff305 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 14:34:36 -0500 Subject: [PATCH 234/238] Async tests for owntracks device tracker (#18681) --- .../device_tracker/test_owntracks.py | 2180 +++++++++-------- 1 file changed, 1106 insertions(+), 1074 deletions(-) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index eaf17fb53f4..2d7397692f8 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1,17 +1,15 @@ """The tests for the Owntracks device tracker.""" -import asyncio import json -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest from tests.common import ( - assert_setup_component, fire_mqtt_message, mock_coro, mock_component, - get_test_home_assistant, mock_mqtt_component) + assert_setup_component, async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component) import homeassistant.components.device_tracker.owntracks as owntracks -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME -from homeassistant.util.async_ import run_coroutine_threadsafe USER = 'greg' DEVICE = 'phone' @@ -275,982 +273,1016 @@ 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") +@pytest.fixture +def setup_comp(hass): + """Initialize components.""" + mock_component(hass, 'group') + mock_component(hass, 'zone') + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) + + hass.states.async_set( + 'zone.inner_2', 'zoning', INNER_ZONE) + + hass.states.async_set( + 'zone.outer', 'zoning', OUTER_ZONE) -class BaseMQTT(unittest.TestCase): - """Base MQTT assert functions.""" +@pytest.fixture +def context(hass, setup_comp): + """Set up the mocked context.""" + patcher = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patcher.start() - hass = None + orig_context = owntracks.OwnTracksContext - def send_message(self, topic, message, corrupt=False): - """Test the sending of a message.""" - str_message = json.dumps(message) - if corrupt: - mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX - else: - mod_message = str_message - fire_mqtt_message(self.hass, topic, mod_message) - self.hass.block_till_done() + context = None - def assert_location_state(self, location): - """Test the assertion of a location state.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.state == location + def store_context(*args): + nonlocal context + context = orig_context(*args) + return context - def assert_location_latitude(self, latitude): - """Test the assertion of a location latitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('latitude') == latitude - - def assert_location_longitude(self, longitude): - """Test the assertion of a location longitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('longitude') == longitude - - def assert_location_accuracy(self, accuracy): - """Test the assertion of a location accuracy.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('gps_accuracy') == accuracy - - def assert_location_source_type(self, source_type): - """Test the assertion of source_type.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('source_type') == source_type - - -class TestDeviceTrackerOwnTracks(BaseMQTT): - """Test the OwnTrack sensor.""" - - # pylint: disable=invalid-name - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') - - patcher = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - patcher.start() - self.addCleanup(patcher.stop) - - orig_context = owntracks.OwnTracksContext - - def store_context(*args): - self.context = orig_context(*args) - return self.context - - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', store_context), \ + assert_setup_component(1, device_tracker.DOMAIN): + assert hass.loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }}) - - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.inner_2', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.outer', 'zoning', OUTER_ZONE) - - # Clear state between tests - # NB: state "None" is not a state that is created by Device - # so when we compare state to None in the tests this - # is really checking that it is still in its original - # test case state. See Device.async_update. - self.hass.states.set(DEVICE_TRACKER_STATE, None) - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - - def assert_mobile_tracker_state(self, location, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker state.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.state == location - - def assert_mobile_tracker_latitude(self, latitude, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker latitude.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('latitude') == latitude - - def assert_mobile_tracker_accuracy(self, accuracy, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker accuracy.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('gps_accuracy') == accuracy - - def test_location_invalid_devid(self): # pylint: disable=invalid-name - """Test the update of a location.""" - self.send_message('owntracks/paulus/nexus-5x', LOCATION_MESSAGE) - state = self.hass.states.get('device_tracker.paulus_nexus5x') - assert state.state == 'outer' - - def test_location_update(self): - """Test the update of a location.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_location_inaccurate_gps(self): - """Test the location for inaccurate GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - def test_location_zero_accuracy_gps(self): - """Ignore the location for zero accuracy GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - # ------------------------------------------------------------------------ - # GPS based event entry / exit testing - - def test_event_gps_entry_exit(self): - """Test the entry event.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # Exit switches back to GPS - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Now sending a location update moves me again. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_gps_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_inaccurate(self): - """Test the event for inaccurate entry.""" - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) - - # I enter the zone even though the message GPS was inaccurate. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_gps_entry_exit_inaccurate(self): - """Test the event for inaccurate exit.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) - - # Exit doesn't use inaccurate gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_exit_zero_accuracy(self): - """Test entry/exit events with accuracy zero.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) - - # Exit doesn't use zero gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_exit_outside_zone_sets_away(self): - """Test the event for exit zone.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Exit message far away GPS location - message = build_message( - {'lon': 90.0, - 'lat': 90.0}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Exit forces zone change to away - self.assert_location_state(STATE_NOT_HOME) - - def test_event_gps_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_ENTER_MESSAGE['lat']) - self.assert_location_state('inner') - - def test_event_gps_exit_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - - def test_event_entry_zone_loading_dash(self): - """Test the event for zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - message = build_message( - {'desc': "-inner"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - def test_events_only_on(self): - """Test events_only config suppresses location updates.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = True - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Ignored location update. Location remains at previous. - self.assert_location_state(STATE_NOT_HOME) - - def test_events_only_off(self): - """Test when events_only is False.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = False - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Location update processed - self.assert_location_state('outer') - - def test_event_source_type_entry_exit(self): - """Test the entry and exit events of source type.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # source_type should be gps when entering using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) - - # We should be able to enter a beacon zone even inside a gps zone - self.assert_location_source_type('bluetooth_le') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # source_type should be gps when leaving using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) - - self.assert_location_source_type('bluetooth_le') - - # Region Beacon based event entry / exit testing - - def test_event_region_entry_exit(self): - """Test the entry event.""" - # Seeing a beacon named "inner" - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # Exit switches back to GPS but the beacon has no coords - # so I am still located at the center of the inner region - # until I receive a location update. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - # Now sending a location update moves me again. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_region_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_region_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # See 'inner' region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # See 'inner_2' region beacon - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_region_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner_2 zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner_2') - - def test_event_beacon_unknown_zone_no_location(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. Except - # in this case my Device hasn't had a location message - # yet so it's in an odd state where it has state.state - # None and no GPS coords so set the beacon to. - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My current state is None because I haven't seen a - # location message or a GPS or Region # Beacon event - # message. None is the state the test harness set for - # the Device during test case setup. - self.assert_location_state('None') - - # home is the state of a Device constructed through - # the normal code path on it's first observation with - # the conditions I pass along. - self.assert_mobile_tracker_state('home', 'unknown') - - def test_event_beacon_unknown_zone(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. First I - # set my location so that my state is 'outer' - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My state is still outer and now the unknown beacon - # has joined me at outer. - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer', 'unknown') - - def test_event_beacon_entry_zone_loading_dash(self): - """Test the event for beacon zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - - message = build_message( - {'desc': "-inner"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # ------------------------------------------------------------------------ - # Mobile Beacon based event entry / exit testing - - def test_mobile_enter_move_beacon(self): - """Test the movement of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see the 'keys' beacon. I set the location of the - # beacon_keys tracker to my current device location. - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(LOCATION_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - # Location update to outside of defined zones. - # I am now 'not home' and neither are my keys. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - - self.assert_location_state(STATE_NOT_HOME) - self.assert_mobile_tracker_state(STATE_NOT_HOME) - - not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] - self.assert_location_latitude(not_home_lat) - self.assert_mobile_tracker_latitude(not_home_lat) - - def test_mobile_enter_exit_region_beacon(self): - """Test the enter and the exit of a mobile beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # GPS enter message should move beacon - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_state(REGION_GPS_ENTER_MESSAGE['desc']) - - # Exit inner zone to outer zone should move beacon to - # center of outer zone - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_exit_move_beacon(self): - """Test the exit move of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Exit mobile beacon, should set location - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Move after exit should do nothing - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_multiple_async_enter_exit(self): - """Test the multiple entering.""" - # Test race condition - for _ in range(0, 20): - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) - - fire_mqtt_message( - self.hass, EVENT_TOPIC, + }})) + + def get_context(): + """Get the current context.""" + return context + + yield get_context + + patcher.stop() + + +async def send_message(hass, topic, message, corrupt=False): + """Test the sending of a message.""" + str_message = json.dumps(message) + if corrupt: + mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX + else: + mod_message = str_message + async_fire_mqtt_message(hass, topic, mod_message) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +def assert_location_state(hass, location): + """Test the assertion of a location state.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.state == location + + +def assert_location_latitude(hass, latitude): + """Test the assertion of a location latitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('latitude') == latitude + + +def assert_location_longitude(hass, longitude): + """Test the assertion of a location longitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('longitude') == longitude + + +def assert_location_accuracy(hass, accuracy): + """Test the assertion of a location accuracy.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('gps_accuracy') == accuracy + + +def assert_location_source_type(hass, source_type): + """Test the assertion of source_type.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('source_type') == source_type + + +def assert_mobile_tracker_state(hass, location, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker state.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.state == location + + +def assert_mobile_tracker_latitude(hass, latitude, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker latitude.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('latitude') == latitude + + +def assert_mobile_tracker_accuracy(hass, accuracy, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker accuracy.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('gps_accuracy') == accuracy + + +async def test_location_invalid_devid(hass, context): + """Test the update of a location.""" + await send_message(hass, 'owntracks/paulus/nexus-5x', LOCATION_MESSAGE) + state = hass.states.get('device_tracker.paulus_nexus5x') + assert state.state == 'outer' + + +async def test_location_update(hass, context): + """Test the update of a location.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_location_inaccurate_gps(hass, context): + """Test the location for inaccurate GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +async def test_location_zero_accuracy_gps(hass, context): + """Ignore the location for zero accuracy GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +# ------------------------------------------------------------------------ +# GPS based event entry / exit testing +async def test_event_gps_entry_exit(hass, context): + """Test the entry event.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # Exit switches back to GPS + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + # Left clean zone state + assert not context().regions_entered[USER] + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Now sending a location update moves me again. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_gps_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_inaccurate(hass, context): + """Test the event for inaccurate entry.""" + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) + + # I enter the zone even though the message GPS was inaccurate. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_entry_exit_inaccurate(hass, context): + """Test the event for inaccurate exit.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) + + # Exit doesn't use inaccurate gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_exit_zero_accuracy(hass, context): + """Test entry/exit events with accuracy zero.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) + + # Exit doesn't use zero gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_exit_outside_zone_sets_away(hass, context): + """Test the event for exit zone.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Exit message far away GPS location + message = build_message( + {'lon': 90.0, + 'lat': 90.0}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Exit forces zone change to away + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_event_gps_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_ENTER_MESSAGE['lat']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_exit_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + + +async def test_event_entry_zone_loading_dash(hass, context): + """Test the event for zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + message = build_message( + {'desc': "-inner"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +async def test_events_only_on(hass, context): + """Test events_only config suppresses location updates.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = True + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Ignored location update. Location remains at previous. + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_events_only_off(hass, context): + """Test when events_only is False.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = False + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Location update processed + assert_location_state(hass, 'outer') + + +async def test_event_source_type_entry_exit(hass, context): + """Test the entry and exit events of source type.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # source_type should be gps when entering using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) + + # We should be able to enter a beacon zone even inside a gps zone + assert_location_source_type(hass, 'bluetooth_le') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # source_type should be gps when leaving using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) + + assert_location_source_type(hass, 'bluetooth_le') + + +# Region Beacon based event entry / exit testing +async def test_event_region_entry_exit(hass, context): + """Test the entry event.""" + # Seeing a beacon named "inner" + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # Exit switches back to GPS but the beacon has no coords + # so I am still located at the center of the inner region + # until I receive a location update. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # Left clean zone state + assert not context().regions_entered[USER] + + # Now sending a location update moves me again. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_region_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_region_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # See 'inner' region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # See 'inner_2' region beacon + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_region_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner_2 zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner_2') + + +async def test_event_beacon_unknown_zone_no_location(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. Except + # in this case my Device hasn't had a location message + # yet so it's in an odd state where it has state.state + # None and no GPS coords so set the beacon to. + hass.states.async_set(DEVICE_TRACKER_STATE, None) + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My current state is None because I haven't seen a + # location message or a GPS or Region # Beacon event + # message. None is the state the test harness set for + # the Device during test case setup. + assert_location_state(hass, 'None') + + # home is the state of a Device constructed through + # the normal code path on it's first observation with + # the conditions I pass along. + assert_mobile_tracker_state(hass, 'home', 'unknown') + + +async def test_event_beacon_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. First I + # set my location so that my state is 'outer' + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My state is still outer and now the unknown beacon + # has joined me at outer. + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer', 'unknown') + + +async def test_event_beacon_entry_zone_loading_dash(hass, context): + """Test the event for beacon zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + + message = build_message( + {'desc': "-inner"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +# ------------------------------------------------------------------------ +# Mobile Beacon based event entry / exit testing +async def test_mobile_enter_move_beacon(hass, context): + """Test the movement of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see the 'keys' beacon. I set the location of the + # beacon_keys tracker to my current device location. + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, LOCATION_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + # Location update to outside of defined zones. + # I am now 'not home' and neither are my keys. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + + assert_location_state(hass, STATE_NOT_HOME) + assert_mobile_tracker_state(hass, STATE_NOT_HOME) + + not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] + assert_location_latitude(hass, not_home_lat) + assert_mobile_tracker_latitude(hass, not_home_lat) + + +async def test_mobile_enter_exit_region_beacon(hass, context): + """Test the enter and the exit of a mobile beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # GPS enter message should move beacon + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_state(hass, REGION_GPS_ENTER_MESSAGE['desc']) + + # Exit inner zone to outer zone should move beacon to + # center of outer zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_exit_move_beacon(hass, context): + """Test the exit move of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Exit mobile beacon, should set location + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Move after exit should do nothing + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_multiple_async_enter_exit(hass, context): + """Test the multiple entering.""" + # Test race condition + for _ in range(0, 20): + async_fire_mqtt_message( + hass, EVENT_TOPIC, json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) - self.hass.block_till_done() - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - def test_mobile_multiple_enter_exit(self): - """Test the multiple entering.""" - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + await hass.async_block_till_done() + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 - def test_complex_movement(self): - """Test a complex sequence representative of real-world use.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') +async def test_mobile_multiple_enter_exit(hass, context): + """Test the multiple entering.""" + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') +async def test_complex_movement(hass, context): + """Test a complex sequence representative of real-world use.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') - # Slightly odd, I leave the location by gps before I lose - # sight of the region beacon. This is also a little odd in - # that my GPS coords are now in the 'outer' zone but I did not - # "enter" that zone when I started up so my location is not - # the center of OUTER_ZONE, but rather just my GPS location. + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - # gps out of inner event and location - location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - # region beacon leave inner - location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - # lose keys mobile beacon - lost_keys_location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, lost_keys_location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_latitude(lost_keys_location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') + # Slightly odd, I leave the location by gps before I lose + # sight of the region beacon. This is also a little odd in + # that my GPS coords are now in the 'outer' zone but I did not + # "enter" that zone when I started up so my location is not + # the center of OUTER_ZONE, but rather just my GPS location. - # gps leave outer - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_latitude(LOCATION_MESSAGE_NOT_HOME['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') + # gps out of inner event and location + location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') - # location move not home - location_message = build_message( - {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, - 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, - LOCATION_MESSAGE_NOT_HOME) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') + # region beacon leave inner + location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') - def test_complex_movement_sticky_keys_beacon(self): - """Test a complex sequence which was previously broken.""" - # I am not_home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') + # lose keys mobile beacon + lost_keys_location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, lost_keys_location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_latitude(hass, lost_keys_location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') + # gps leave outer + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_latitude(hass, LOCATION_MESSAGE_NOT_HOME['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # location move not home + location_message = build_message( + {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, + 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, + LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - # This sequence of moves would cause keys to follow - # greg_phone around even after the OwnTracks sent - # a mobile beacon 'leave' event for the keys. - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') +async def test_complex_movement_sticky_keys_beacon(hass, context): + """Test a complex sequence which was previously broken.""" + # I am not_home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - # enter inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - # enter keys - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # This sequence of moves would cause keys to follow + # greg_phone around even after the OwnTracks sent + # a mobile beacon 'leave' event for the keys. + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - # GPS leave inner region, I'm in the 'outer' region now - # but on GPS coords - leave_location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, leave_location_message) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('inner') - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) + # enter inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - def test_waypoint_import_simple(self): - """Test a simple import of list of waypoints.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[1]) - assert wayp is not None + # enter keys + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - def test_waypoint_import_blacklist(self): - """Test import of list of waypoints for blacklisted user.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - def test_waypoint_import_no_whitelist(self): - """Test import of list of waypoints with no whitelist set.""" - @asyncio.coroutine - def mock_see(**kwargs): - """Fake see method for owntracks.""" - return + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - test_config = { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_MQTT_TOPIC: 'owntracks/#', - } - 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(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is not None + # GPS leave inner region, I'm in the 'outer' region now + # but on GPS coords + leave_location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, leave_location_message) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'inner') + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) - def test_waypoint_import_bad_json(self): - """Test importing a bad JSON payload.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message, True) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None - def test_waypoint_import_existing(self): - """Test importing a zone that exists.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - 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(WAYPOINTS_TOPIC, waypoints_message) - new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp == new_wayp +async def test_waypoint_import_simple(hass, context): + """Test a simple import of list of waypoints.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[1]) + assert wayp is not None - 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]) - assert 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() - assert not self.send_message(LWT_TOPIC, LWT_MESSAGE) - patch_handler.stop() +async def test_waypoint_import_blacklist(hass, context): + """Test import of list of waypoints for blacklisted user.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None - 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() - assert not self.send_message(BAD_TOPIC, BAD_MESSAGE) - patch_handler.stop() + +async def test_waypoint_import_no_whitelist(hass, context): + """Test import of list of waypoints with no whitelist set.""" + async def mock_see(**kwargs): + """Fake see method for owntracks.""" + return + + test_config = { + CONF_PLATFORM: 'owntracks', + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_MQTT_TOPIC: 'owntracks/#', + } + await owntracks.async_setup_scanner(hass, test_config, mock_see) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is not None + + +async def test_waypoint_import_bad_json(hass, context): + """Test importing a bad JSON payload.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message, True) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None + + +async def test_waypoint_import_existing(hass, context): + """Test importing a zone that exists.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Get the first waypoint exported + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + # Send an update + waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + new_wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp == new_wayp + + +async def test_single_waypoint_import(hass, context): + """Test single waypoint message.""" + waypoint_message = WAYPOINT_MESSAGE.copy() + await send_message(hass, WAYPOINT_TOPIC, waypoint_message) + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + + +async def test_not_implemented_message(hass, context): + """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() + assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) + patch_handler.stop() + + +async def test_unsupported_message(hass, context): + """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() + assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) + patch_handler.stop() def generate_ciphers(secret): @@ -1310,162 +1342,162 @@ def mock_cipher(): return len(TEST_SECRET_KEY), mock_decrypt -class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): - """Test the OwnTrack sensor.""" +@pytest.fixture +def config_context(hass, setup_comp): + """Set up the mocked context.""" + patch_load = patch( + 'homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])) + patch_load.start() - # pylint: disable=invalid-name + patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patch_save.start() - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') + yield - self.patch_load = patch( - 'homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])) - self.patch_load.start() + patch_load.stop() + patch_save.stop() - self.patch_save = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - self.patch_save.start() - def teardown_method(self, method): - """Tear down resources.""" - self.patch_load.stop() - self.patch_save.stop() - self.hass.stop() +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload(hass, config_context): + """Test encrypted payload.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: TEST_SECRET_KEY, + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload(self): - """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_topic_key(self): - """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_topic_key(hass, config_context): + """Test encrypted payload with a topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_key(self): - """Test encrypted payload with no key, .""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_key(self): - """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_key(hass, config_context): + """Test encrypted payload with no key, .""" + assert hass.states.get(DEVICE_TRACKER_STATE) is None + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + # key missing + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_topic_key(self): - """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_topic_key(self): - """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_key(hass, config_context): + """Test encrypted payload with wrong key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: 'wrong key', + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_topic_key(hass, config_context): + """Test encrypted payload with wrong topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_topic_key(hass, config_context): + """Test encrypted payload with no topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +async def test_encrypted_payload_libsodium(hass, config_context): + """Test sending encrypted message payload.""" try: - import libnacl + import libnacl # noqa: F401 except (ImportError, OSError): - libnacl = None + pytest.skip("libnacl/libsodium is not installed") + return - @unittest.skipUnless(libnacl, "libnacl/libsodium is not installed") - def test_encrypted_payload_libsodium(self): - """Test sending encrypted message payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: TEST_SECRET_KEY, + }}) - self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - def test_customized_mqtt_topic(self): - """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) - topic = 'mytracks/{}/{}'.format(USER, DEVICE) +async def test_customized_mqtt_topic(hass, config_context): + """Test subscribing to a custom mqtt topic.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_MQTT_TOPIC: 'mytracks/#', + }}) - self.send_message(topic, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + topic = 'mytracks/{}/{}'.format(USER, DEVICE) - def test_region_mapping(self): - """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) + await send_message(hass, topic, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) - assert message['desc'] == 'foo' +async def test_region_mapping(hass, config_context): + """Test region to zone mapping.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }}) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) + + message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) + assert message['desc'] == 'foo' + + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') From f860cac4ea5ab1276dd1e228d92e80433651cc50 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:20:13 +0100 Subject: [PATCH 235/238] OwnTracks Config Entry (#18759) * OwnTracks Config Entry * Fix test * Fix headers * Lint * Username for android only * Update translations * Tweak translation * Create config entry if not there * Update reqs * Types * Lint --- .../components/device_tracker/__init__.py | 11 + .../components/device_tracker/owntracks.py | 158 +------------ .../device_tracker/owntracks_http.py | 82 ------- .../owntracks/.translations/en.json | 17 ++ .../components/owntracks/__init__.py | 219 ++++++++++++++++++ .../components/owntracks/config_flow.py | 79 +++++++ .../components/owntracks/strings.json | 17 ++ homeassistant/config_entries.py | 1 + homeassistant/setup.py | 34 ++- requirements_all.txt | 3 +- .../device_tracker/test_owntracks.py | 154 ++++++------ tests/components/owntracks/__init__.py | 1 + .../components/owntracks/test_config_flow.py | 1 + .../test_init.py} | 97 +++++--- tests/test_setup.py | 35 ++- 15 files changed, 554 insertions(+), 355 deletions(-) delete mode 100644 homeassistant/components/device_tracker/owntracks_http.py create mode 100644 homeassistant/components/owntracks/.translations/en.json create mode 100644 homeassistant/components/owntracks/__init__.py create mode 100644 homeassistant/components/owntracks/config_flow.py create mode 100644 homeassistant/components/owntracks/strings.json create mode 100644 tests/components/owntracks/__init__.py create mode 100644 tests/components/owntracks/test_config_flow.py rename tests/components/{device_tracker/test_owntracks_http.py => owntracks/test_init.py} (51%) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a43a7c93bdc..ad792d035cc 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -182,6 +182,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): setup = await hass.async_add_job( platform.setup_scanner, hass, p_config, tracker.see, disc_info) + elif hasattr(platform, 'async_setup_entry'): + setup = await platform.async_setup_entry( + hass, p_config, tracker.async_see) else: raise HomeAssistantError("Invalid device_tracker platform.") @@ -197,6 +200,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): except Exception: # pylint: disable=broad-except _LOGGER.exception("Error setting up platform %s", p_type) + hass.data[DOMAIN] = async_setup_platform + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN)] if setup_tasks: @@ -230,6 +235,12 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): return True +async def async_setup_entry(hass, entry): + """Set up an entry.""" + await hass.data[DOMAIN](entry.domain, entry) + return True + + class DeviceTracker: """Representation of a device tracker.""" diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 10f71450f69..ae2b9d6146b 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -7,55 +7,29 @@ https://home-assistant.io/components/device_tracker.owntracks/ import base64 import json import logging -from collections import defaultdict -import voluptuous as vol - -from homeassistant.components import mqtt -import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS + ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS ) +from homeassistant.components.owntracks import DOMAIN as OT_DOMAIN from homeassistant.const import STATE_HOME -from homeassistant.core import callback from homeassistant.util import slugify, decorator -REQUIREMENTS = ['libnacl==1.6.1'] + +DEPENDENCIES = ['owntracks'] _LOGGER = logging.getLogger(__name__) HANDLERS = decorator.Registry() -BEACON_DEV_ID = 'beacon' -CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' -CONF_SECRET = 'secret' -CONF_WAYPOINT_IMPORT = 'waypoints' -CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -CONF_MQTT_TOPIC = 'mqtt_topic' -CONF_REGION_MAPPING = 'region_mapping' -CONF_EVENTS_ONLY = 'events_only' - -DEPENDENCIES = ['mqtt'] - -DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' -REGION_MAPPING = {} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, - vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, - vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): - mqtt.valid_subscribe_topic, - vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( - cv.ensure_list, [cv.string]), - vol.Optional(CONF_SECRET): vol.Any( - vol.Schema({vol.Optional(cv.string): cv.string}), - cv.string), - vol.Optional(CONF_REGION_MAPPING, default=REGION_MAPPING): dict -}) +async def async_setup_entry(hass, entry, async_see): + """Set up OwnTracks based off an entry.""" + hass.data[OT_DOMAIN]['context'].async_see = async_see + hass.helpers.dispatcher.async_dispatcher_connect( + OT_DOMAIN, async_handle_message) + return True def get_cipher(): @@ -72,29 +46,6 @@ def get_cipher(): return (KEYLEN, decrypt) -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an OwnTracks tracker.""" - context = context_from_config(async_see, config) - - async def async_handle_mqtt_message(topic, payload, qos): - """Handle incoming OwnTracks message.""" - try: - message = json.loads(payload) - except ValueError: - # If invalid JSON - _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return - - message['topic'] = topic - - await async_handle_message(hass, context, message) - - await mqtt.async_subscribe( - hass, context.mqtt_topic, async_handle_mqtt_message, 1) - - return True - - def _parse_topic(topic, subscribe_topic): """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. @@ -202,93 +153,6 @@ def _decrypt_payload(secret, topic, ciphertext): return None -def context_from_config(async_see, config): - """Create an async context from Home Assistant config.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - region_mapping = config.get(CONF_REGION_MAPPING) - events_only = config.get(CONF_EVENTS_ONLY) - mqtt_topic = config.get(CONF_MQTT_TOPIC) - - return OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist, - region_mapping, events_only, mqtt_topic) - - -class OwnTracksContext: - """Hold the current OwnTracks context.""" - - def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, - waypoint_whitelist, region_mapping, events_only, mqtt_topic): - """Initialize an OwnTracks context.""" - self.async_see = async_see - self.secret = secret - self.max_gps_accuracy = max_gps_accuracy - self.mobile_beacons_active = defaultdict(set) - self.regions_entered = defaultdict(list) - self.import_waypoints = import_waypoints - self.waypoint_whitelist = waypoint_whitelist - self.region_mapping = region_mapping - self.events_only = events_only - self.mqtt_topic = mqtt_topic - - @callback - def async_valid_accuracy(self, message): - """Check if we should ignore this message.""" - acc = message.get('acc') - - if acc is None: - return False - - try: - acc = float(acc) - except ValueError: - return False - - if acc == 0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - message['_type'], message) - return False - - if self.max_gps_accuracy is not None and \ - acc > self.max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - message['_type'], self.max_gps_accuracy, - message) - return False - - return True - - async def async_see_beacons(self, hass, dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - - # Mobile beacons should always be set to the location of the - # tracking device. I get the device state and make the necessary - # changes to kwargs. - device_tracker_state = hass.states.get( - "device_tracker.{}".format(dev_id)) - - if device_tracker_state is not None: - acc = device_tracker_state.attributes.get("gps_accuracy") - lat = device_tracker_state.attributes.get("latitude") - lon = device_tracker_state.attributes.get("longitude") - kwargs['gps_accuracy'] = acc - kwargs['gps'] = (lat, lon) - - # the battery state applies to the tracking device, not the beacon - # kwargs location is the beacon's configured lat/lon - kwargs.pop('battery', None) - for beacon in self.mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - await self.async_see(**kwargs) - - @HANDLERS.register('location') async def async_handle_location_message(hass, context, message): """Handle a location message.""" @@ -485,6 +349,8 @@ async def async_handle_message(hass, context, message): """Handle an OwnTracks message.""" msgtype = message.get('_type') + _LOGGER.debug("Received %s", message) + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) await handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py deleted file mode 100644 index b9f379e7534..00000000000 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Device tracker platform that adds support for OwnTracks over HTTP. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.owntracks_http/ -""" -import json -import logging -import re - -from aiohttp.web import Response -import voluptuous as vol - -# pylint: disable=unused-import -from homeassistant.components.device_tracker.owntracks import ( # NOQA - PLATFORM_SCHEMA, REQUIREMENTS, async_handle_message, context_from_config) -from homeassistant.const import CONF_WEBHOOK_ID -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['webhook'] - -_LOGGER = logging.getLogger(__name__) - -EVENT_RECEIVED = 'owntracks_http_webhook_received' -EVENT_RESPONSE = 'owntracks_http_webhook_response_' - -DOMAIN = 'device_tracker.owntracks_http' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_WEBHOOK_ID): cv.string -}) - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up OwnTracks HTTP component.""" - context = context_from_config(async_see, config) - - subscription = context.mqtt_topic - topic = re.sub('/#$', '', subscription) - - async def handle_webhook(hass, webhook_id, request): - """Handle webhook callback.""" - headers = request.headers - data = dict() - - if 'X-Limit-U' in headers: - data['user'] = headers['X-Limit-U'] - elif 'u' in request.query: - data['user'] = request.query['u'] - else: - return Response( - body=json.dumps({'error': 'You need to supply username.'}), - content_type="application/json" - ) - - if 'X-Limit-D' in headers: - data['device'] = headers['X-Limit-D'] - elif 'd' in request.query: - data['device'] = request.query['d'] - else: - return Response( - body=json.dumps({'error': 'You need to supply device name.'}), - content_type="application/json" - ) - - message = await request.json() - - message['topic'] = '{}/{}/{}'.format(topic, data['user'], - data['device']) - - try: - await async_handle_message(hass, context, message) - return Response(body=json.dumps([]), status=200, - content_type="application/json") - except ValueError: - _LOGGER.error("Received invalid JSON") - return None - - hass.components.webhook.async_register( - 'owntracks', 'OwnTracks', config['webhook_id'], handle_webhook) - - return True diff --git a/homeassistant/components/owntracks/.translations/en.json b/homeassistant/components/owntracks/.translations/en.json new file mode 100644 index 00000000000..a34077a0a83 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + }, + "step": { + "user": { + "description": "Are you sure you want to set up OwnTracks?", + "title": "Set up OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py new file mode 100644 index 00000000000..a5da7f5fc48 --- /dev/null +++ b/homeassistant/components/owntracks/__init__.py @@ -0,0 +1,219 @@ +"""Component for OwnTracks.""" +from collections import defaultdict +import json +import logging +import re + +from aiohttp.web import json_response +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.components import mqtt +from homeassistant.setup import async_when_setup +import homeassistant.helpers.config_validation as cv + +from .config_flow import CONF_SECRET + +DOMAIN = "owntracks" +REQUIREMENTS = ['libnacl==1.6.1'] +DEPENDENCIES = ['device_tracker', 'webhook'] + +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT = 'waypoints' +CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +CONF_MQTT_TOPIC = 'mqtt_topic' +CONF_REGION_MAPPING = 'region_mapping' +CONF_EVENTS_ONLY = 'events_only' +BEACON_DEV_ID = 'beacon' + +DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default={}): { + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), + vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( + cv.ensure_list, [cv.string]), + vol.Optional(CONF_SECRET): vol.Any( + vol.Schema({vol.Optional(cv.string): cv.string}), + cv.string), + vol.Optional(CONF_REGION_MAPPING, default={}): dict, + vol.Optional(CONF_WEBHOOK_ID): cv.string, + } +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Initialize OwnTracks component.""" + hass.data[DOMAIN] = { + 'config': config[DOMAIN] + } + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={} + )) + + return True + + +async def async_setup_entry(hass, entry): + """Set up OwnTracks entry.""" + config = hass.data[DOMAIN]['config'] + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) or entry.data[CONF_SECRET] + region_mapping = config.get(CONF_REGION_MAPPING) + events_only = config.get(CONF_EVENTS_ONLY) + mqtt_topic = config.get(CONF_MQTT_TOPIC) + + context = OwnTracksContext(hass, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist, + region_mapping, events_only, mqtt_topic) + + webhook_id = config.get(CONF_WEBHOOK_ID) or entry.data[CONF_WEBHOOK_ID] + + hass.data[DOMAIN]['context'] = context + + async_when_setup(hass, 'mqtt', async_connect_mqtt) + + hass.components.webhook.async_register( + DOMAIN, 'OwnTracks', webhook_id, handle_webhook) + + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'device_tracker')) + + return True + + +async def async_connect_mqtt(hass, component): + """Subscribe to MQTT topic.""" + context = hass.data[DOMAIN]['context'] + + async def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" + try: + message = json.loads(payload) + except ValueError: + # If invalid JSON + _LOGGER.error("Unable to parse payload as JSON: %s", payload) + return + + message['topic'] = topic + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + + await hass.components.mqtt.async_subscribe( + context.mqtt_topic, async_handle_mqtt_message, 1) + + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + context = hass.data[DOMAIN]['context'] + message = await request.json() + + # Android doesn't populate topic + if 'topic' not in message: + headers = request.headers + user = headers.get('X-Limit-U') + device = headers.get('X-Limit-D', user) + + if user is None: + _LOGGER.warning('Set a username in Connection -> Identification') + return json_response( + {'error': 'You need to supply username.'}, + status=400 + ) + + topic_base = re.sub('/#$', '', context.mqtt_topic) + message['topic'] = '{}/{}/{}'.format(topic_base, user, device) + + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + return json_response([]) + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, hass, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist, region_mapping, events_only, mqtt_topic): + """Initialize an OwnTracks context.""" + self.hass = hass + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(set) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + self.region_mapping = region_mapping + self.events_only = events_only + self.mqtt_topic = mqtt_topic + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + async def async_see(self, **data): + """Send a see message to the device tracker.""" + await self.hass.components.device_tracker.async_see(**data) + + async def async_see_beacons(self, hass, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + + # Mobile beacons should always be set to the location of the + # tracking device. I get the device state and make the necessary + # changes to kwargs. + device_tracker_state = hass.states.get( + "device_tracker.{}".format(dev_id)) + + if device_tracker_state is not None: + acc = device_tracker_state.attributes.get("gps_accuracy") + lat = device_tracker_state.attributes.get("latitude") + lon = device_tracker_state.attributes.get("longitude") + kwargs['gps_accuracy'] = acc + kwargs['gps'] = (lat, lon) + + # the battery state applies to the tracking device, not the beacon + # kwargs location is the beacon's configured lat/lon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + await self.async_see(**kwargs) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py new file mode 100644 index 00000000000..88362946428 --- /dev/null +++ b/homeassistant/components/owntracks/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for OwnTracks.""" +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.auth.util import generate_secret + +CONF_SECRET = 'secret' + + +def supports_encryption(): + """Test if we support encryption.""" + try: + # pylint: disable=unused-variable + import libnacl # noqa + return True + except OSError: + return False + + +@config_entries.HANDLERS.register('owntracks') +class OwnTracksFlow(config_entries.ConfigFlow): + """Set up OwnTracks.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create OwnTracks webhook.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + secret = generate_secret(16) + + if supports_encryption(): + secret_desc = ( + "The encryption key is {secret} " + "(on Android under preferences -> advanced)") + else: + secret_desc = ( + "Encryption is not supported because libsodium is not " + "installed.") + + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + }, + description_placeholders={ + 'secret': secret_desc, + 'webhook_url': webhook_url, + 'android_url': + 'https://play.google.com/store/apps/details?' + 'id=org.owntracks.android', + 'ios_url': + 'https://itunes.apple.com/us/app/owntracks/id692424691?mt=8', + 'docs_url': + 'https://www.home-assistant.io/components/owntracks/' + } + ) + + async def async_step_import(self, user_input): + """Import a config flow from configuration.""" + webhook_id = self.hass.components.webhook.async_generate_id() + secret = generate_secret(16) + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + } + ) diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json new file mode 100644 index 00000000000..fcf7305d714 --- /dev/null +++ b/homeassistant/components/owntracks/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "title": "OwnTracks", + "step": { + "user": { + "title": "Set up OwnTracks", + "description": "Are you sure you want to set up OwnTracks?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 42bc8b089da..2325f35822f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ FLOWS = [ 'mqtt', 'nest', 'openuv', + 'owntracks', 'point', 'rainmachine', 'simplisafe', diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 057843834c0..cc7c4284f9c 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -4,7 +4,7 @@ import logging.handlers from timeit import default_timer as timer from types import ModuleType -from typing import Optional, Dict, List +from typing import Awaitable, Callable, Optional, Dict, List from homeassistant import requirements, core, loader, config as conf_util from homeassistant.config import async_notify_setup_error @@ -248,3 +248,35 @@ async def async_process_deps_reqs( raise HomeAssistantError("Could not install all requirements.") processed.add(name) + + +@core.callback +def async_when_setup( + hass: core.HomeAssistant, component: str, + when_setup_cb: Callable[ + [core.HomeAssistant, str], Awaitable[None]]) -> None: + """Call a method when a component is setup.""" + async def when_setup() -> None: + """Call the callback.""" + try: + await when_setup_cb(hass, component) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error handling when_setup callback for %s', + component) + + # Running it in a new task so that it always runs after + if component in hass.config.components: + hass.async_create_task(when_setup()) + return + + unsub = None + + async def loaded_event(event: core.Event) -> None: + """Call the callback.""" + if event.data[ATTR_COMPONENT] != component: + return + + unsub() # type: ignore + await when_setup() + + unsub = hass.bus.async_listen(EVENT_COMPONENT_LOADED, loaded_event) diff --git a/requirements_all.txt b/requirements_all.txt index bc53dbce24e..197f9be02d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -558,8 +558,7 @@ konnected==0.1.4 # homeassistant.components.eufy lakeside==0.10 -# homeassistant.components.device_tracker.owntracks -# homeassistant.components.device_tracker.owntracks_http +# homeassistant.components.owntracks libnacl==1.6.1 # homeassistant.components.dyson diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 2d7397692f8..6f457f30ed0 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -4,12 +4,11 @@ from asynctest import patch import pytest from tests.common import ( - assert_setup_component, async_fire_mqtt_message, mock_coro, mock_component, - async_mock_mqtt_component) -import homeassistant.components.device_tracker.owntracks as owntracks + async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component, MockConfigEntry) +from homeassistant.components import owntracks from homeassistant.setup import async_setup_component -from homeassistant.components import device_tracker -from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME +from homeassistant.const import STATE_NOT_HOME USER = 'greg' DEVICE = 'phone' @@ -290,6 +289,25 @@ def setup_comp(hass): 'zone.outer', 'zoning', OUTER_ZONE) +async def setup_owntracks(hass, config, + ctx_cls=owntracks.OwnTracksContext): + """Set up OwnTracks.""" + await async_mock_mqtt_component(hass) + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', ctx_cls): + assert await async_setup_component( + hass, 'owntracks', {'owntracks': config}) + + @pytest.fixture def context(hass, setup_comp): """Set up the mocked context.""" @@ -306,20 +324,11 @@ def context(hass, setup_comp): context = orig_context(*args) return context - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert hass.loop.run_until_complete(async_setup_component( - hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }})) + hass.loop.run_until_complete(setup_owntracks(hass, { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] + }, store_context)) def get_context(): """Get the current context.""" @@ -1211,19 +1220,14 @@ async def test_waypoint_import_blacklist(hass, context): assert wayp is None -async def test_waypoint_import_no_whitelist(hass, context): +async def test_waypoint_import_no_whitelist(hass, config_context): """Test import of list of waypoints with no whitelist set.""" - async def mock_see(**kwargs): - """Fake see method for owntracks.""" - return - - test_config = { - CONF_PLATFORM: 'owntracks', + await setup_owntracks(hass, { CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, CONF_MQTT_TOPIC: 'owntracks/#', - } - await owntracks.async_setup_scanner(hass, test_config, mock_see) + }) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states @@ -1364,12 +1368,9 @@ def config_context(hass, setup_comp): mock_cipher) async def test_encrypted_payload(hass, config_context): """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1378,13 +1379,11 @@ async def test_encrypted_payload(hass, config_context): mock_cipher) async def test_encrypted_payload_topic_key(hass, config_context): """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + } + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1394,12 +1393,10 @@ async def test_encrypted_payload_topic_key(hass, config_context): async def test_encrypted_payload_no_key(hass, config_context): """Test encrypted payload with no key, .""" assert hass.states.get(DEVICE_TRACKER_STATE) is None - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) + await setup_owntracks(hass, { + CONF_SECRET: { + } + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1408,12 +1405,9 @@ async def test_encrypted_payload_no_key(hass, config_context): mock_cipher) async def test_encrypted_payload_wrong_key(hass, config_context): """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) + await setup_owntracks(hass, { + CONF_SECRET: 'wrong key', + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1422,13 +1416,11 @@ async def test_encrypted_payload_wrong_key(hass, config_context): mock_cipher) async def test_encrypted_payload_wrong_topic_key(hass, config_context): """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }, + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1437,13 +1429,10 @@ async def test_encrypted_payload_wrong_topic_key(hass, config_context): mock_cipher) async def test_encrypted_payload_no_topic_key(hass, config_context): """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1456,12 +1445,9 @@ async def test_encrypted_payload_libsodium(hass, config_context): pytest.skip("libnacl/libsodium is not installed") return - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1469,12 +1455,9 @@ async def test_encrypted_payload_libsodium(hass, config_context): async def test_customized_mqtt_topic(hass, config_context): """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) + await setup_owntracks(hass, { + CONF_MQTT_TOPIC: 'mytracks/#', + }) topic = 'mytracks/{}/{}'.format(USER, DEVICE) @@ -1484,14 +1467,11 @@ async def test_customized_mqtt_topic(hass, config_context): async def test_region_mapping(hass, config_context): """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) + await setup_owntracks(hass, { + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }) hass.states.async_set( 'zone.inner', 'zoning', INNER_ZONE) diff --git a/tests/components/owntracks/__init__.py b/tests/components/owntracks/__init__.py new file mode 100644 index 00000000000..a95431913b2 --- /dev/null +++ b/tests/components/owntracks/__init__.py @@ -0,0 +1 @@ +"""Tests for OwnTracks component.""" diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py new file mode 100644 index 00000000000..079fdfafea0 --- /dev/null +++ b/tests/components/owntracks/test_config_flow.py @@ -0,0 +1 @@ +"""Tests for OwnTracks config flow.""" diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/owntracks/test_init.py similarity index 51% rename from tests/components/device_tracker/test_owntracks_http.py rename to tests/components/owntracks/test_init.py index a49f30c6839..ee79c8b9e10 100644 --- a/tests/components/device_tracker/test_owntracks_http.py +++ b/tests/components/owntracks/test_init.py @@ -1,14 +1,11 @@ """Test the owntracks_http platform.""" import asyncio -from unittest.mock import patch -import os import pytest -from homeassistant.components import device_tracker from homeassistant.setup import async_setup_component -from tests.common import mock_component, mock_coro +from tests.common import mock_component, MockConfigEntry MINIMAL_LOCATION_MESSAGE = { '_type': 'location', @@ -36,38 +33,33 @@ LOCATION_MESSAGE = { } -@pytest.fixture(autouse=True) -def owntracks_http_cleanup(hass): - """Remove known_devices.yaml.""" - try: - os.remove(hass.config.path(device_tracker.YAML_DEVICES)) - except OSError: - pass - - @pytest.fixture def mock_client(hass, aiohttp_client): """Start the Hass HTTP component.""" mock_component(hass, 'group') mock_component(hass, 'zone') - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])): - hass.loop.run_until_complete( - async_setup_component(hass, 'device_tracker', { - 'device_tracker': { - 'platform': 'owntracks_http', - 'webhook_id': 'owntracks_test' - } - })) + mock_component(hass, 'device_tracker') + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + hass.loop.run_until_complete(async_setup_component(hass, 'owntracks', {})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine def test_handle_valid_message(mock_client): """Test that we forward messages correctly to OwnTracks.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?' - 'u=test&d=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -78,9 +70,14 @@ def test_handle_valid_message(mock_client): @asyncio.coroutine def test_handle_valid_minimal_message(mock_client): """Test that we forward messages correctly to OwnTracks.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?' - 'u=test&d=test', - json=MINIMAL_LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=MINIMAL_LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -91,8 +88,14 @@ def test_handle_valid_minimal_message(mock_client): @asyncio.coroutine def test_handle_value_error(mock_client): """Test we don't disclose that this is a valid webhook.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test' - '?u=test&d=test', json='') + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json='', + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -103,10 +106,15 @@ def test_handle_value_error(mock_client): @asyncio.coroutine def test_returns_error_missing_username(mock_client): """Test that an error is returned when username is missing.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?d=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-d': 'Pixel', + } + ) - assert resp.status == 200 + assert resp.status == 400 json = yield from resp.json() assert json == {'error': 'You need to supply username.'} @@ -115,10 +123,27 @@ def test_returns_error_missing_username(mock_client): @asyncio.coroutine def test_returns_error_missing_device(mock_client): """Test that an error is returned when device name is missing.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?u=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + } + ) assert resp.status == 200 json = yield from resp.json() - assert json == {'error': 'You need to supply device name.'} + assert json == [] + + +async def test_config_flow_import(hass): + """Test that we automatically create a config flow.""" + assert not hass.config_entries.async_entries('owntracks') + assert await async_setup_component(hass, 'owntracks', { + 'owntracks': { + + } + }) + await hass.async_block_till_done() + assert hass.config_entries.async_entries('owntracks') diff --git a/tests/test_setup.py b/tests/test_setup.py index 29712f40ebc..2e44ee539d7 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -9,7 +9,8 @@ import logging import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_COMPONENT_LOADED) import homeassistant.config as config_util from homeassistant import setup, loader import homeassistant.util.dt as dt_util @@ -459,3 +460,35 @@ def test_platform_no_warn_slow(hass): hass, 'test_component1', {}) assert result assert not mock_call.called + + +async def test_when_setup_already_loaded(hass): + """Test when setup.""" + calls = [] + + async def mock_callback(hass, component): + """Mock callback.""" + calls.append(component) + + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == [] + + hass.config.components.add('test') + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Event listener should be gone + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Should be called right away + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == ['test', 'test'] From 311c796da7e9dac2c74aaec380d7541ec8de318e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:17:37 +0100 Subject: [PATCH 236/238] Default to on if logged in (#18766) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/prefs.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index b968850668d..4f4b0c582fc 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -248,7 +248,7 @@ class Cloud: info = await self.hass.async_add_job(load_config) - await self.prefs.async_initialize(not info) + await self.prefs.async_initialize(bool(info)) if info is None: return diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index d29b356cfc0..7e1ec6a0232 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -27,6 +27,7 @@ class CloudPreferences: PREF_ENABLE_GOOGLE: logged_in, PREF_GOOGLE_ALLOW_UNLOCK: False, } + await self._store.async_save(prefs) self._prefs = prefs From 05915775e37fa2be565dda6348947ed63dd37f92 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:47:37 +0100 Subject: [PATCH 237/238] Bumped version to 0.83.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9fc6d61cb33..585d9ff3b83 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0b3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 1364114dc189a33ab96c9837e71470b10ad43c1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 10:57:40 +0100 Subject: [PATCH 238/238] Bumped version to 0.83.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 585d9ff3b83..dc00267cdf8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3)