diff --git a/.coveragerc b/.coveragerc index 3d369eed073..f06e9356d21 100644 --- a/.coveragerc +++ b/.coveragerc @@ -215,6 +215,9 @@ omit = homeassistant/components/opencv.py homeassistant/components/*/opencv.py + homeassistant/components/openuv.py + homeassistant/components/*/openuv.py + homeassistant/components/pilight.py homeassistant/components/*/pilight.py @@ -440,6 +443,7 @@ omit = homeassistant/components/device_tracker/netgear.py homeassistant/components/device_tracker/nmap_tracker.py homeassistant/components/device_tracker/ping.py + homeassistant/components/device_tracker/ritassist.py homeassistant/components/device_tracker/sky_hub.py homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/swisscom.py @@ -509,6 +513,7 @@ omit = homeassistant/components/media_player/denon.py homeassistant/components/media_player/denonavr.py homeassistant/components/media_player/directv.py + homeassistant/components/media_player/dlna_dmr.py homeassistant/components/media_player/dunehd.py homeassistant/components/media_player/emby.py homeassistant/components/media_player/epson.py @@ -532,6 +537,7 @@ omit = homeassistant/components/media_player/pandora.py homeassistant/components/media_player/philips_js.py homeassistant/components/media_player/pioneer.py + homeassistant/components/media_player/pjlink.py homeassistant/components/media_player/plex.py homeassistant/components/media_player/roku.py homeassistant/components/media_player/russound_rio.py @@ -632,6 +638,7 @@ omit = homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py homeassistant/components/sensor/emoncms.py + homeassistant/components/sensor/enphase_envoy.py homeassistant/components/sensor/envirophat.py homeassistant/components/sensor/etherscan.py homeassistant/components/sensor/fastdotcom.py diff --git a/.travis.yml b/.travis.yml index 0a3d710810c..920e8b57047 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,8 @@ matrix: - python: "3.5.3" env: TOXENV=typing - python: "3.5.3" - env: TOXENV=py35 + env: TOXENV=cov + after_success: coveralls - python: "3.6" env: TOXENV=py36 - python: "3.7" @@ -45,4 +46,3 @@ deploy: on: branch: dev condition: $TOXENV = lint -after_success: coveralls diff --git a/CODEOWNERS b/CODEOWNERS index 556791b879c..53f577d02eb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -98,6 +98,8 @@ homeassistant/components/konnected.py @heythisisnate homeassistant/components/*/konnected.py @heythisisnate homeassistant/components/matrix.py @tinloaf homeassistant/components/*/matrix.py @tinloaf +homeassistant/components/openuv.py @bachya +homeassistant/components/*/openuv.py @bachya homeassistant/components/qwikswitch.py @kellerza homeassistant/components/*/qwikswitch.py @kellerza homeassistant/components/rainmachine/* @bachya diff --git a/Dockerfile b/Dockerfile index 75d9e9eb716..c84e6162d04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,6 @@ LABEL maintainer="Paulus Schoutsen " #ENV INSTALL_OPENALPR no #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no -#ENV INSTALL_PHANTOMJS no #ENV INSTALL_SSOCR no #ENV INSTALL_IPERF3 no diff --git a/README.rst b/README.rst index 7f0d41b00ea..6cf19d89c3c 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -Home Assistant |Build Status| |Coverage Status| |Chat Status| -============================================================= +Home Assistant |Build Status| |Coverage Status| |Chat Status| |Reviewed by Hound| +================================================================================= Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control. @@ -33,6 +33,8 @@ of a component, check the `Home Assistant help section Awaitable['AuthManager']: """Initialize an auth manager from config.""" store = auth_store.AuthStore(hass) if provider_configs: @@ -51,7 +56,6 @@ class AuthManager: self.login_flow = data_entry_flow.FlowManager( hass, self._async_create_login_flow, self._async_finish_login_flow) - self._access_tokens = OrderedDict() @property def active(self): @@ -178,43 +182,64 @@ class AuthManager: return await self._store.async_create_refresh_token(user, client_id) - async def async_get_refresh_token(self, token): + async def async_get_refresh_token(self, token_id): + """Get refresh token by id.""" + return await self._store.async_get_refresh_token(token_id) + + async def async_get_refresh_token_by_token(self, token): """Get refresh token by token.""" - return await self._store.async_get_refresh_token(token) + return await self._store.async_get_refresh_token_by_token(token) @callback def async_create_access_token(self, refresh_token): """Create a new access token.""" - access_token = models.AccessToken(refresh_token=refresh_token) - self._access_tokens[access_token.token] = access_token - return access_token + # pylint: disable=no-self-use + return jwt.encode({ + 'iss': refresh_token.id, + 'iat': dt_util.utcnow(), + 'exp': dt_util.utcnow() + refresh_token.access_token_expiration, + }, refresh_token.jwt_key, algorithm='HS256').decode() - @callback - def async_get_access_token(self, token): - """Get an access token.""" - tkn = self._access_tokens.get(token) - - if tkn is None: - _LOGGER.debug('Attempt to get non-existing access token') + async def async_validate_access_token(self, token): + """Return if an access token is valid.""" + try: + unverif_claims = jwt.decode(token, verify=False) + except jwt.InvalidTokenError: return None - if tkn.expired or not tkn.refresh_token.user.is_active: - if tkn.expired: - _LOGGER.debug('Attempt to get expired access token') - else: - _LOGGER.debug('Attempt to get access token for inactive user') - self._access_tokens.pop(token) + refresh_token = await self.async_get_refresh_token( + unverif_claims.get('iss')) + + if refresh_token is None: + jwt_key = '' + issuer = '' + else: + jwt_key = refresh_token.jwt_key + issuer = refresh_token.id + + try: + jwt.decode( + token, + jwt_key, + leeway=10, + issuer=issuer, + algorithms=['HS256'] + ) + except jwt.InvalidTokenError: return None - return tkn + if not refresh_token.user.is_active: + return None - async def _async_create_login_flow(self, handler, *, source, data): + return refresh_token + + async def _async_create_login_flow(self, handler, *, context, data): """Create a login flow.""" auth_provider = self._providers[handler] - return await auth_provider.async_credential_flow() + return await auth_provider.async_credential_flow(context) - async def _async_finish_login_flow(self, result): + async def _async_finish_login_flow(self, context, result): """Result of a credential login flow.""" if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: return None diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 8fd66d4bbb7..806cd109d78 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -1,6 +1,7 @@ """Storage for auth models.""" from collections import OrderedDict from datetime import timedelta +import hmac from homeassistant.util import dt as dt_util @@ -110,22 +111,36 @@ class AuthStore: async def async_create_refresh_token(self, user, client_id=None): """Create a new token for a user.""" refresh_token = models.RefreshToken(user=user, client_id=client_id) - user.refresh_tokens[refresh_token.token] = refresh_token + user.refresh_tokens[refresh_token.id] = refresh_token await self.async_save() return refresh_token - async def async_get_refresh_token(self, token): - """Get refresh token by token.""" + async def async_get_refresh_token(self, token_id): + """Get refresh token by id.""" if self._users is None: await self.async_load() for user in self._users.values(): - refresh_token = user.refresh_tokens.get(token) + refresh_token = user.refresh_tokens.get(token_id) if refresh_token is not None: return refresh_token return None + async def async_get_refresh_token_by_token(self, token): + """Get refresh token by token.""" + if self._users is None: + await self.async_load() + + found = None + + for user in self._users.values(): + for refresh_token in user.refresh_tokens.values(): + if hmac.compare_digest(refresh_token.token, token): + found = refresh_token + + return found + async def async_load(self): """Load the users.""" data = await self._store.async_load() @@ -153,9 +168,11 @@ class AuthStore: data=cred_dict['data'], )) - refresh_tokens = OrderedDict() - for rt_dict in data['refresh_tokens']: + # Filter out the old keys that don't have jwt_key (pre-0.76) + if 'jwt_key' not in rt_dict: + continue + token = models.RefreshToken( id=rt_dict['id'], user=users[rt_dict['user_id']], @@ -164,18 +181,9 @@ class AuthStore: access_token_expiration=timedelta( seconds=rt_dict['access_token_expiration']), token=rt_dict['token'], + jwt_key=rt_dict['jwt_key'] ) - refresh_tokens[token.id] = token - users[rt_dict['user_id']].refresh_tokens[token.token] = token - - for ac_dict in data['access_tokens']: - refresh_token = refresh_tokens[ac_dict['refresh_token_id']] - token = models.AccessToken( - refresh_token=refresh_token, - created_at=dt_util.parse_datetime(ac_dict['created_at']), - token=ac_dict['token'], - ) - refresh_token.access_tokens.append(token) + users[rt_dict['user_id']].refresh_tokens[token.id] = token self._users = users @@ -213,27 +221,15 @@ class AuthStore: 'access_token_expiration': refresh_token.access_token_expiration.total_seconds(), 'token': refresh_token.token, + 'jwt_key': refresh_token.jwt_key, } for user in self._users.values() for refresh_token in user.refresh_tokens.values() ] - access_tokens = [ - { - 'id': user.id, - 'refresh_token_id': refresh_token.id, - 'created_at': access_token.created_at.isoformat(), - 'token': access_token.token, - } - for user in self._users.values() - for refresh_token in user.refresh_tokens.values() - for access_token in refresh_token.access_tokens - ] - data = { 'users': users, 'credentials': credentials, - 'access_tokens': access_tokens, 'refresh_tokens': refresh_tokens, } diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 38e054dc7cf..3f49c56bce6 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -39,26 +39,8 @@ class RefreshToken: default=ACCESS_TOKEN_EXPIRATION) token = attr.ib(type=str, default=attr.Factory(lambda: generate_secret(64))) - access_tokens = attr.ib(type=list, default=attr.Factory(list), cmp=False) - - -@attr.s(slots=True) -class AccessToken: - """Access token to access the API. - - These will only ever be stored in memory and not be persisted. - """ - - refresh_token = attr.ib(type=RefreshToken) - created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - token = attr.ib(type=str, - default=attr.Factory(generate_secret)) - - @property - def expired(self): - """Return if this token has expired.""" - expires = self.created_at + self.refresh_token.access_token_expiration - return dt_util.utcnow() > expires + jwt_key = attr.ib(type=str, + default=attr.Factory(lambda: generate_secret(64))) @attr.s(slots=True) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 68cc1c7edd2..ac5b6107b8a 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -123,7 +123,7 @@ class AuthProvider: # Implement by extending class - async def async_credential_flow(self): + async def async_credential_flow(self, context): """Return the data flow for logging in with auth provider.""" raise NotImplementedError diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index e9693b09634..5a2355264ab 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -158,7 +158,7 @@ class HassAuthProvider(AuthProvider): self.data = Data(self.hass) await self.data.async_load() - async def async_credential_flow(self): + async def async_credential_flow(self, context): """Return a flow to login.""" return LoginFlow(self) diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index c86c8eb71f1..96f824140ed 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -31,7 +31,7 @@ class InvalidAuthError(HomeAssistantError): class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" - async def async_credential_flow(self): + async def async_credential_flow(self, context): """Return a flow to login.""" return LoginFlow(self) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 1f92fb60f13..f2f467e07ec 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -36,7 +36,7 @@ class LegacyApiPasswordAuthProvider(AuthProvider): DEFAULT_TITLE = 'Legacy API Password' - async def async_credential_flow(self): + async def async_credential_flow(self, context): """Return a flow to login.""" return LoginFlow(self) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 06e28c42b13..bf1577cbf01 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -10,6 +10,7 @@ Component design guidelines: import asyncio import itertools as it import logging +from typing import Awaitable import homeassistant.core as ha import homeassistant.config as conf_util @@ -109,7 +110,7 @@ def async_reload_core_config(hass): @asyncio.coroutine -def async_setup(hass, config): +def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: """Set up general services related to Home Assistant.""" @asyncio.coroutine def async_handle_turn_service(service): diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index eb25ee8fb08..5f268a95f5d 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) _CONFIGURING = {} -REQUIREMENTS = ['py-august==0.4.0'] +REQUIREMENTS = ['py-august==0.6.0'] DEFAULT_TIMEOUT = 10 ACTIVITY_FETCH_LIMIT = 10 diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 0b2b4fb1a2e..102bfe58b55 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -155,7 +155,7 @@ class GrantTokenView(HomeAssistantView): access_token = hass.auth.async_create_access_token(refresh_token) return self.json({ - 'access_token': access_token.token, + 'access_token': access_token, 'token_type': 'Bearer', 'refresh_token': refresh_token.token, 'expires_in': @@ -178,7 +178,7 @@ class GrantTokenView(HomeAssistantView): 'error': 'invalid_request', }, status_code=400) - refresh_token = await hass.auth.async_get_refresh_token(token) + refresh_token = await hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: return self.json({ @@ -193,7 +193,7 @@ class GrantTokenView(HomeAssistantView): access_token = hass.auth.async_create_access_token(refresh_token) return self.json({ - 'access_token': access_token.token, + 'access_token': access_token, 'token_type': 'Bearer', 'expires_in': int(refresh_token.access_token_expiration.total_seconds()), diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index ef7f8a9b292..48f7ab06ab4 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,6 +1,10 @@ """Helpers to resolve client ID/secret.""" +import asyncio +from html.parser import HTMLParser from ipaddress import ip_address, ip_network -from urllib.parse import urlparse +from urllib.parse import urlparse, urljoin + +from aiohttp.client_exceptions import ClientError # IP addresses of loopback interfaces ALLOWED_IPS = ( @@ -16,7 +20,7 @@ ALLOWED_NETWORKS = ( ) -def verify_redirect_uri(client_id, redirect_uri): +async def verify_redirect_uri(hass, client_id, redirect_uri): """Verify that the client and redirect uri match.""" try: client_id_parts = _parse_client_id(client_id) @@ -25,16 +29,75 @@ def verify_redirect_uri(client_id, redirect_uri): redirect_parts = _parse_url(redirect_uri) - # IndieAuth 4.2.2 allows for redirect_uri to be on different domain - # but needs to be specified in link tag when fetching `client_id`. - # This is not implemented. - # Verify redirect url and client url have same scheme and domain. - return ( + is_valid = ( client_id_parts.scheme == redirect_parts.scheme and client_id_parts.netloc == redirect_parts.netloc ) + if is_valid: + return True + + # IndieAuth 4.2.2 allows for redirect_uri to be on different domain + # but needs to be specified in link tag when fetching `client_id`. + redirect_uris = await fetch_redirect_uris(hass, client_id) + return redirect_uri in redirect_uris + + +class LinkTagParser(HTMLParser): + """Parser to find link tags.""" + + def __init__(self, rel): + """Initialize a link tag parser.""" + super().__init__() + self.rel = rel + self.found = [] + + def handle_starttag(self, tag, attrs): + """Handle finding a start tag.""" + if tag != 'link': + return + + attrs = dict(attrs) + + if attrs.get('rel') == self.rel: + self.found.append(attrs.get('href')) + + +async def fetch_redirect_uris(hass, url): + """Find link tag with redirect_uri values. + + IndieAuth 4.2.2 + + The client SHOULD publish one or more tags or Link HTTP headers with + a rel attribute of redirect_uri at the client_id URL. + + We limit to the first 10kB of the page. + + We do not implement extracting redirect uris from headers. + """ + session = hass.helpers.aiohttp_client.async_get_clientsession() + parser = LinkTagParser('redirect_uri') + chunks = 0 + try: + resp = await session.get(url, timeout=5) + + async for data in resp.content.iter_chunked(1024): + parser.feed(data.decode()) + chunks += 1 + + if chunks == 10: + break + + except (asyncio.TimeoutError, ClientError): + pass + + # Authorization endpoints verifying that a redirect_uri is allowed for use + # by a client MUST look for an exact match of the given redirect_uri in the + # request against the list of redirect_uris discovered after resolving any + # relative URLs. + return [urljoin(url, found) for found in parser.found] + def verify_client_id(client_id): """Verify that the client id is valid.""" diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 6d1b6cf4ecf..7b80e52a8d7 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -54,7 +54,6 @@ have type "create_entry" and "result" key will contain an authorization code. "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", "handler": ["insecure_example", null], "result": "411ee2f916e648d691e937ae9344681e", - "source": "user", "title": "Example", "type": "create_entry", "version": 1 @@ -68,8 +67,6 @@ from homeassistant.components.http.ban import process_wrong_login, \ log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.helpers.data_entry_flow import ( - FlowManagerIndexView, FlowManagerResourceView) from . import indieauth @@ -97,13 +94,41 @@ class AuthProvidersView(HomeAssistantView): } for provider in request.app['hass'].auth.auth_providers]) -class LoginFlowIndexView(FlowManagerIndexView): +def _prepare_result_json(result): + """Convert result to JSON.""" + if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + data = result.copy() + data.pop('result') + data.pop('data') + return data + + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class LoginFlowIndexView(HomeAssistantView): """View to create a config flow.""" url = '/auth/login_flow' name = 'api:auth:login_flow' requires_auth = False + def __init__(self, flow_mgr): + """Initialize the flow manager index view.""" + self._flow_mgr = flow_mgr + async def get(self, request): """Do not allow index of flows in progress.""" return aiohttp.web.Response(status=405) @@ -116,15 +141,26 @@ class LoginFlowIndexView(FlowManagerIndexView): @log_invalid_auth async def post(self, request, data): """Create a new login flow.""" - if not indieauth.verify_redirect_uri(data['client_id'], - data['redirect_uri']): + if not await indieauth.verify_redirect_uri( + request.app['hass'], data['client_id'], data['redirect_uri']): return self.json_message('invalid client id or redirect uri', 400) - # pylint: disable=no-value-for-parameter - return await super().post(request) + if isinstance(data['handler'], list): + handler = tuple(data['handler']) + else: + handler = data['handler'] + + try: + result = await self._flow_mgr.async_init(handler, context={}) + except data_entry_flow.UnknownHandler: + return self.json_message('Invalid handler specified', 404) + except data_entry_flow.UnknownStep: + return self.json_message('Handler does not support init', 400) + + return self.json(_prepare_result_json(result)) -class LoginFlowResourceView(FlowManagerResourceView): +class LoginFlowResourceView(HomeAssistantView): """View to interact with the flow manager.""" url = '/auth/login_flow/{flow_id}' @@ -133,10 +169,10 @@ class LoginFlowResourceView(FlowManagerResourceView): def __init__(self, flow_mgr, store_credentials): """Initialize the login flow resource view.""" - super().__init__(flow_mgr) + self._flow_mgr = flow_mgr self._store_credentials = store_credentials - async def get(self, request, flow_id): + async def get(self, request): """Do not allow getting status of a flow in progress.""" return self.json_message('Invalid flow specified', 404) @@ -164,9 +200,18 @@ class LoginFlowResourceView(FlowManagerResourceView): if result['errors'] is not None and \ result['errors'].get('base') == 'invalid_auth': await process_wrong_login(request) - return self.json(self._prepare_result_json(result)) + return self.json(_prepare_result_json(result)) result.pop('data') result['result'] = self._store_credentials(client_id, result['result']) return self.json(result) + + async def delete(self, request, flow_id): + """Cancel a flow in progress.""" + try: + self._flow_mgr.async_abort(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + return self.json_message('Flow aborted') diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index 72110eb50c9..75906e8ac5d 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -122,7 +122,6 @@ class BayesianBinarySensor(BinarySensorDevice): def async_added_to_hass(self): """Call when entity about to be added.""" @callback - # pylint: disable=invalid-name def async_threshold_sensor_state_listener(entity, old_state, new_state): """Handle sensor state changes.""" diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py index 6966f61129c..962817827f0 100644 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -16,10 +16,7 @@ DEPENDENCIES = ['homematicip_cloud'] _LOGGER = logging.getLogger(__name__) -ATTR_WINDOW_STATE = 'window_state' -ATTR_EVENT_DELAY = 'event_delay' -ATTR_MOTION_DETECTED = 'motion_detected' -ATTR_ILLUMINATION = 'illumination' +STATE_SMOKE_OFF = 'IDLE_OFF' async def async_setup_platform(hass, config, async_add_devices, @@ -30,15 +27,18 @@ async def async_setup_platform(hass, config, async_add_devices, async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the HomematicIP binary sensor from a config entry.""" - from homematicip.device import (ShutterContact, MotionDetectorIndoor) + from homematicip.aio.device import ( + AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: - if isinstance(device, ShutterContact): + if isinstance(device, AsyncShutterContact): devices.append(HomematicipShutterContact(home, device)) - elif isinstance(device, MotionDetectorIndoor): + elif isinstance(device, AsyncMotionDetectorIndoor): devices.append(HomematicipMotionDetector(home, device)) + elif isinstance(device, AsyncSmokeDetector): + devices.append(HomematicipSmokeDetector(home, device)) if devices: async_add_devices(devices) @@ -47,10 +47,6 @@ async def async_setup_entry(hass, config_entry, async_add_devices): class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): """HomematicIP shutter contact.""" - def __init__(self, home, device): - """Initialize the shutter contact.""" - super().__init__(home, device) - @property def device_class(self): """Return the class of this sensor.""" @@ -69,11 +65,7 @@ class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): - """MomematicIP motion detector.""" - - def __init__(self, home, device): - """Initialize the shutter contact.""" - super().__init__(home, device) + """HomematicIP motion detector.""" @property def device_class(self): @@ -86,3 +78,17 @@ class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): if self._device.sabotage: return True return self._device.motionDetected + + +class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice): + """HomematicIP smoke detector.""" + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'smoke' + + @property + def is_on(self): + """Return true if smoke is detected.""" + return self._device.smokeDetectorAlarmType != STATE_SMOKE_OFF diff --git a/homeassistant/components/binary_sensor/openuv.py b/homeassistant/components/binary_sensor/openuv.py new file mode 100644 index 00000000000..3a2732d3be0 --- /dev/null +++ b/homeassistant/components/binary_sensor/openuv.py @@ -0,0 +1,103 @@ +""" +This platform provides binary sensors for OpenUV data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.openuv/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.openuv import ( + BINARY_SENSORS, DATA_PROTECTION_WINDOW, DOMAIN, TOPIC_UPDATE, + TYPE_PROTECTION_WINDOW, OpenUvEntity) +from homeassistant.util.dt import as_local, parse_datetime, utcnow + +DEPENDENCIES = ['openuv'] +_LOGGER = logging.getLogger(__name__) + +ATTR_PROTECTION_WINDOW_STARTING_TIME = 'start_time' +ATTR_PROTECTION_WINDOW_STARTING_UV = 'start_uv' +ATTR_PROTECTION_WINDOW_ENDING_TIME = 'end_time' +ATTR_PROTECTION_WINDOW_ENDING_UV = 'end_uv' + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the OpenUV binary sensor platform.""" + if discovery_info is None: + return + + openuv = hass.data[DOMAIN] + + binary_sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon = BINARY_SENSORS[sensor_type] + binary_sensors.append( + OpenUvBinarySensor(openuv, sensor_type, name, icon)) + + async_add_devices(binary_sensors, True) + + +class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice): + """Define a binary sensor for OpenUV.""" + + def __init__(self, openuv, sensor_type, name, icon): + """Initialize the sensor.""" + super().__init__(openuv) + + self._icon = icon + self._latitude = openuv.client.latitude + self._longitude = openuv.client.longitude + self._name = name + self._sensor_type = sensor_type + self._state = None + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}_{2}'.format( + self._latitude, self._longitude, 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, TOPIC_UPDATE, self._update_data) + + async def async_update(self): + """Update the state.""" + data = self.openuv.data[DATA_PROTECTION_WINDOW]['result'] + if self._sensor_type == TYPE_PROTECTION_WINDOW: + self._state = parse_datetime( + data['from_time']) <= utcnow() <= parse_datetime( + data['to_time']) + self._attrs.update({ + ATTR_PROTECTION_WINDOW_ENDING_TIME: + as_local(parse_datetime(data['to_time'])), + ATTR_PROTECTION_WINDOW_ENDING_UV: data['to_uv'], + ATTR_PROTECTION_WINDOW_STARTING_UV: data['from_uv'], + ATTR_PROTECTION_WINDOW_STARTING_TIME: + as_local(parse_datetime(data['from_time'])), + }) diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 360671d1cea..39681c894b3 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -86,7 +86,6 @@ class ThresholdSensor(BinarySensorDevice): self._state = False self.sensor_value = None - # pylint: disable=invalid-name @callback def async_threshold_sensor_state_listener( entity, old_state, new_state): diff --git a/homeassistant/components/binary_sensor/velbus.py b/homeassistant/components/binary_sensor/velbus.py index 214edcf9463..8438be0d784 100644 --- a/homeassistant/components/binary_sensor/velbus.py +++ b/homeassistant/components/binary_sensor/velbus.py @@ -4,93 +4,34 @@ Support for Velbus Binary Sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.velbus/ """ -import asyncio import logging - -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_DEVICES from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.binary_sensor import PLATFORM_SCHEMA -from homeassistant.components.velbus import DOMAIN -import homeassistant.helpers.config_validation as cv - - -DEPENDENCIES = ['velbus'] +from homeassistant.components.velbus import ( + DOMAIN as VELBUS_DOMAIN, VelbusEntity) _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ - { - vol.Required('module'): cv.positive_int, - vol.Required('channel'): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Optional('is_pushbutton'): cv.boolean - } - ]) -}) +DEPENDENCIES = ['velbus'] -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up Velbus binary sensors.""" - velbus = hass.data[DOMAIN] - - add_devices(VelbusBinarySensor(sensor, velbus) - for sensor in config[CONF_DEVICES]) + 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(VelbusBinarySensor(module, channel)) + async_add_devices(sensors) -class VelbusBinarySensor(BinarySensorDevice): +class VelbusBinarySensor(VelbusEntity, BinarySensorDevice): """Representation of a Velbus Binary Sensor.""" - def __init__(self, binary_sensor, velbus): - """Initialize a Velbus light.""" - self._velbus = velbus - self._name = binary_sensor[CONF_NAME] - self._module = binary_sensor['module'] - self._channel = binary_sensor['channel'] - self._is_pushbutton = 'is_pushbutton' in binary_sensor \ - and binary_sensor['is_pushbutton'] - self._state = False - - @asyncio.coroutine - def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - yield from self.hass.async_add_job( - self._velbus.subscribe, self._on_message) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.PushButtonStatusMessage): - if message.address == self._module and \ - self._channel in message.get_channels(): - if self._is_pushbutton: - if self._channel in message.closed: - self._toggle() - else: - pass - else: - self._toggle() - - def _toggle(self): - if self._state is True: - self._state = False - else: - self._state = True - self.schedule_update_ha_state() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the display name of this sensor.""" - return self._name - @property def is_on(self): """Return true if the sensor is on.""" - return self._state + return self._module.is_closed(self._channel) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index 00d2a95e356..4a9809e9974 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['holidays==0.9.5'] +REQUIREMENTS = ['holidays==0.9.6'] # List of all countries currently supported by holidays # There seems to be no way to get the list out at runtime @@ -25,9 +25,9 @@ ALL_COUNTRIES = ['Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', 'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech', 'CZ', 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', 'Finland', 'FI', 'France', 'FRA', 'Germany', - 'DE', 'Hungary', 'HU', 'Ireland', 'Isle of Man', 'Italy', - 'IT', 'Japan', 'JP', 'Mexico', 'MX', 'Netherlands', 'NL', - 'NewZealand', 'NZ', 'Northern Ireland', + 'DE', 'Hungary', 'HU', 'India', 'IND', 'Ireland', + 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', 'Mexico', 'MX', + 'Netherlands', 'NL', 'NewZealand', 'NZ', 'Northern Ireland', 'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT', 'PortugalExt', 'PTE', 'Scotland', 'Slovenia', 'SI', 'Slovakia', 'SK', 'South Africa', 'ZA', 'Spain', 'ES', diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index 30c5a6177b4..ba1f60027ba 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -26,9 +26,6 @@ CONF_PROJECT_DUE_DATE = 'due_date_days' CONF_PROJECT_LABEL_WHITELIST = 'labels' CONF_PROJECT_WHITELIST = 'include_projects' -# https://github.com/PyCQA/pylint/pull/2320 -# pylint: disable=fixme - # Calendar Platform: Does this calendar event last all day? ALL_DAY = 'all_day' # Attribute: All tasks in this project diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py index b575a705f98..4efc2c7d8ba 100644 --- a/homeassistant/components/camera/yi.py +++ b/homeassistant/components/camera/yi.py @@ -57,6 +57,7 @@ class YiCamera(Camera): self._last_url = None self._manager = hass.data[DATA_FFMPEG] self._name = config[CONF_NAME] + self._is_on = True self.host = config[CONF_HOST] self.port = config[CONF_PORT] self.path = config[CONF_PATH] @@ -68,6 +69,11 @@ class YiCamera(Camera): """Camera brand.""" return DEFAULT_BRAND + @property + def is_on(self): + """Determine whether the camera is on.""" + return self._is_on + @property def name(self): """Return the name of this camera.""" @@ -81,7 +87,7 @@ class YiCamera(Camera): try: await ftp.connect(self.host) await ftp.login(self.user, self.passwd) - except StatusCodeError as err: + except (ConnectionRefusedError, StatusCodeError) as err: raise PlatformNotReady(err) try: @@ -101,12 +107,13 @@ class YiCamera(Camera): return None await ftp.quit() - + self._is_on = True return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format( self.user, self.passwd, self.host, self.port, self.path, latest_dir, videos[-1]) except (ConnectionRefusedError, StatusCodeError) as err: _LOGGER.error('Error while fetching video: %s', err) + self._is_on = False return None async def async_camera_image(self): @@ -114,7 +121,7 @@ class YiCamera(Camera): from haffmpeg import ImageFrame, IMAGE_JPEG url = await self._get_latest_video_url() - if url != self._last_url: + if url and url != self._last_url: ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) self._last_image = await asyncio.shield( ffmpeg.get_image( @@ -130,6 +137,9 @@ class YiCamera(Camera): """Generate an HTTP MJPEG stream from the camera.""" from haffmpeg import CameraMjpeg + if not self._is_on: + return + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) await stream.open_camera( self._last_url, extra_cmd=self._extra_arguments) diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index aadf0103c5a..6885f24269a 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,5 +1,5 @@ """Component to embed Google Cast.""" -from homeassistant import data_entry_flow +from homeassistant import config_entries from homeassistant.helpers import config_entry_flow @@ -15,7 +15,7 @@ async def async_setup(hass, config): if conf is not None: hass.async_create_task(hass.config_entries.flow.async_init( - DOMAIN, source=data_entry_flow.SOURCE_IMPORT)) + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) return True diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index b3043689f8c..c8441a9f7af 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['radiotherm==1.3'] +REQUIREMENTS = ['radiotherm==1.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d2aa918eda2..04d2c713cdc 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -7,7 +7,7 @@ from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView) -REQUIREMENTS = ['voluptuous-serialize==1'] +REQUIREMENTS = ['voluptuous-serialize==2.0.0'] @asyncio.coroutine @@ -96,7 +96,7 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): return self.json([ flw for flw in hass.config_entries.flow.async_progress() - if flw['source'] != data_entry_flow.SOURCE_USER]) + if flw['context']['source'] != config_entries.SOURCE_USER]) class ConfigManagerFlowResourceView(FlowManagerResourceView): diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 824e330d6a0..b38a863ebe0 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -4,8 +4,10 @@ Support for Tahoma cover - shutters etc. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.tahoma/ """ +from datetime import timedelta import logging +from homeassistant.util.dt import utcnow from homeassistant.components.cover import CoverDevice, ATTR_POSITION from homeassistant.components.tahoma import ( DOMAIN as TAHOMA_DOMAIN, TahomaDevice) @@ -14,6 +16,13 @@ DEPENDENCIES = ['tahoma'] _LOGGER = logging.getLogger(__name__) +ATTR_MEM_POS = 'memorized_position' +ATTR_RSSI_LEVEL = 'rssi_level' +ATTR_LOCK_START_TS = 'lock_start_ts' +ATTR_LOCK_END_TS = 'lock_end_ts' +ATTR_LOCK_LEVEL = 'lock_level' +ATTR_LOCK_ORIG = 'lock_originator' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tahoma covers.""" @@ -27,27 +36,107 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class TahomaCover(TahomaDevice, CoverDevice): """Representation a Tahoma Cover.""" + def __init__(self, tahoma_device, controller): + """Initialize the device.""" + super().__init__(tahoma_device, controller) + + self._closure = 0 + # 100 equals open + self._position = 100 + self._closed = False + self._rssi_level = None + self._icon = None + # Can be 0 and bigger + self._lock_timer = 0 + self._lock_start_ts = None + self._lock_end_ts = None + # Can be 'comfortLevel1', 'comfortLevel2', 'comfortLevel3', + # 'comfortLevel4', 'environmentProtection', 'humanProtection', + # 'userLevel1', 'userLevel2' + self._lock_level = None + # Can be 'LSC', 'SAAC', 'SFC', 'UPS', 'externalGateway', 'localUser', + # 'myself', 'rain', 'security', 'temperature', 'timer', 'user', 'wind' + self._lock_originator = None + def update(self): """Update method.""" self.controller.get_states([self.tahoma_device]) + # For vertical covers + self._closure = self.tahoma_device.active_states.get( + 'core:ClosureState') + # For horizontal covers + if self._closure is None: + self._closure = self.tahoma_device.active_states.get( + 'core:DeploymentState') + + # For all, if available + if 'core:PriorityLockTimerState' in self.tahoma_device.active_states: + old_lock_timer = self._lock_timer + self._lock_timer = \ + self.tahoma_device.active_states['core:PriorityLockTimerState'] + # Derive timestamps from _lock_timer, only if not already set or + # something has changed + if self._lock_timer > 0: + _LOGGER.debug("Update %s, lock_timer: %d", self._name, + self._lock_timer) + if self._lock_start_ts is None: + self._lock_start_ts = utcnow() + if self._lock_end_ts is None or \ + old_lock_timer != self._lock_timer: + self._lock_end_ts = utcnow() +\ + timedelta(seconds=self._lock_timer) + else: + self._lock_start_ts = None + self._lock_end_ts = None + else: + self._lock_timer = 0 + self._lock_start_ts = None + self._lock_end_ts = None + + self._lock_level = self.tahoma_device.active_states.get( + 'io:PriorityLockLevelState') + + self._lock_originator = self.tahoma_device.active_states.get( + 'io:PriorityLockOriginatorState') + + self._rssi_level = self.tahoma_device.active_states.get( + 'core:RSSILevelState') + + # Define which icon to use + if self._lock_timer > 0: + if self._lock_originator == 'wind': + self._icon = 'mdi:weather-windy' + else: + self._icon = 'mdi:lock-alert' + else: + self._icon = None + + # Define current position. + # _position: 0 is closed, 100 is fully open. + # 'core:ClosureState': 100 is closed, 0 is fully open. + if self._closure is not None: + self._position = 100 - self._closure + if self._position <= 5: + self._position = 0 + if self._position >= 95: + self._position = 100 + self._closed = self._position == 0 + else: + self._position = None + if 'core:OpenClosedState' in self.tahoma_device.active_states: + self._closed = \ + self.tahoma_device.active_states['core:OpenClosedState']\ + == 'closed' + else: + self._closed = False + + _LOGGER.debug("Update %s, position: %d", self._name, self._position) + @property def current_cover_position(self): - """ - Return current position of cover. - - 0 is closed, 100 is fully open. - """ - try: - position = 100 - \ - self.tahoma_device.active_states['core:ClosureState'] - if position <= 5: - return 0 - if position >= 95: - return 100 - return position - except KeyError: - return None + """Return current position of cover.""" + return self._position def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -56,8 +145,7 @@ class TahomaCover(TahomaDevice, CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - return self.current_cover_position == 0 + return self._closed @property def device_class(self): @@ -66,13 +154,47 @@ class TahomaCover(TahomaDevice, CoverDevice): return 'window' return None + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attr = {} + super_attr = super().device_state_attributes + if super_attr is not None: + attr.update(super_attr) + + if 'core:Memorized1PositionState' in self.tahoma_device.active_states: + attr[ATTR_MEM_POS] = self.tahoma_device.active_states[ + 'core:Memorized1PositionState'] + if self._rssi_level is not None: + attr[ATTR_RSSI_LEVEL] = self._rssi_level + if self._lock_start_ts is not None: + attr[ATTR_LOCK_START_TS] = self._lock_start_ts.isoformat() + if self._lock_end_ts is not None: + attr[ATTR_LOCK_END_TS] = self._lock_end_ts.isoformat() + if self._lock_level is not None: + attr[ATTR_LOCK_LEVEL] = self._lock_level + if self._lock_originator is not None: + attr[ATTR_LOCK_ORIG] = self._lock_originator + return attr + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + def open_cover(self, **kwargs): """Open the cover.""" - self.apply_action('open') + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self.apply_action('close') + else: + self.apply_action('open') def close_cover(self, **kwargs): """Close the cover.""" - self.apply_action('close') + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self.apply_action('open') + else: + self.apply_action('close') def stop_cover(self, **kwargs): """Stop the cover.""" @@ -87,5 +209,10 @@ class TahomaCover(TahomaDevice, CoverDevice): 'rts:ExteriorVenetianBlindRTSComponent', 'rts:BlindRTSComponent'): self.apply_action('my') + elif self.tahoma_device.type in \ + ('io:HorizontalAwningIOComponent', + 'io:RollerShutterGenericIOComponent', + 'io:VerticalExteriorAwningIOComponent'): + self.apply_action('stop') else: self.apply_action('stopIdentify') diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index b09b7e15b31..51b496906a2 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -24,7 +24,8 @@ "data": { "allow_clip_sensor": "Import virtueller Sensoren zulassen", "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen" - } + }, + "title": "Weitere Konfigurationsoptionen f\u00fcr deCONZ" } }, "title": "deCONZ Zigbee Gateway" diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index e0982c65f33..eacfe22e818 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/deconz/ """ import voluptuous as vol +from homeassistant import config_entries from homeassistant.const import ( CONF_API_KEY, CONF_EVENT, CONF_HOST, CONF_ID, CONF_PORT, EVENT_HOMEASSISTANT_STOP) @@ -22,7 +23,7 @@ from .const import ( CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DATA_DECONZ_EVENT, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) -REQUIREMENTS = ['pydeconz==42'] +REQUIREMENTS = ['pydeconz==43'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -60,7 +61,9 @@ async def async_setup(hass, config): deconz_config = config[DOMAIN] if deconz_config and not configured_hosts(hass): hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, source='import', data=deconz_config + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + data=deconz_config )) return True @@ -96,7 +99,7 @@ async def async_setup_entry(hass, config_entry): hass.data[DATA_DECONZ_EVENT] = [] hass.data[DATA_DECONZ_UNSUB] = [] - for component in ['binary_sensor', 'light', 'scene', 'sensor']: + for component in ['binary_sensor', 'light', 'scene', 'sensor', 'switch']: hass.async_create_task(hass.config_entries.async_forward_entry_setup( config_entry, component)) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index a6f67506227..fb2eb54232a 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -33,6 +33,10 @@ class DeconzFlowHandler(data_entry_flow.FlowHandler): self.bridges = [] 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. diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 6deee322a15..e7bc5605aee 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -14,3 +14,7 @@ CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' ATTR_DARK = 'dark' ATTR_ON = 'on' + +POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] +SIRENS = ["Warning device"] +SWITCH_TYPES = POWER_PLUGS + SIRENS diff --git a/homeassistant/components/device_tracker/bt_home_hub_5.py b/homeassistant/components/device_tracker/bt_home_hub_5.py index 93bc9270650..21c41df3a1d 100644 --- a/homeassistant/components/device_tracker/bt_home_hub_5.py +++ b/homeassistant/components/device_tracker/bt_home_hub_5.py @@ -5,24 +5,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.bt_home_hub_5/ """ import logging -import re -import xml.etree.ElementTree as ET -import json -from urllib.parse import unquote -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA, + DeviceScanner) from homeassistant.const import CONF_HOST +REQUIREMENTS = ['bthomehub5-devicelist==0.1.1'] + _LOGGER = logging.getLogger(__name__) -_MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') + +CONF_DEFAULT_IP = '192.168.1.254' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string + vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, }) @@ -38,18 +36,19 @@ class BTHomeHub5DeviceScanner(DeviceScanner): def __init__(self, config): """Initialise the scanner.""" + import bthomehub5_devicelist + _LOGGER.info("Initialising BT Home Hub 5") - self.host = config.get(CONF_HOST, '192.168.1.254') + self.host = config[CONF_HOST] self.last_results = {} - self.url = 'http://{}/nonAuth/home_status.xml'.format(self.host) # Test the router is accessible - data = _get_homehub_data(self.url) + data = bthomehub5_devicelist.get_devicelist(self.host) self.success_init = data is not None def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" - self._update_info() + self.update_info() return (device for device in self.last_results) @@ -57,71 +56,23 @@ class BTHomeHub5DeviceScanner(DeviceScanner): """Return the name of the given device or None if we don't know.""" # If not initialised and not already scanned and not found. if device not in self.last_results: - self._update_info() + self.update_info() if not self.last_results: return None return self.last_results.get(device) - def _update_info(self): - """Ensure the information from the BT Home Hub 5 is up to date. - - Return boolean if scanning successful. - """ - if not self.success_init: - return False + def update_info(self): + """Ensure the information from the BT Home Hub 5 is up to date.""" + import bthomehub5_devicelist _LOGGER.info("Scanning") - data = _get_homehub_data(self.url) + data = bthomehub5_devicelist.get_devicelist(self.host) if not data: _LOGGER.warning("Error scanning devices") - return False + return self.last_results = data - - return True - - -def _get_homehub_data(url): - """Retrieve data from BT Home Hub 5 and return parsed result.""" - try: - response = requests.get(url, timeout=5) - except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") - return - if response.status_code == 200: - return _parse_homehub_response(response.text) - _LOGGER.error("Invalid response from Home Hub: %s", response) - - -def _parse_homehub_response(data_str): - """Parse the BT Home Hub 5 data format.""" - root = ET.fromstring(data_str) - - dirty_json = root.find('known_device_list').get('value') - - # Normalise the JavaScript data to JSON. - clean_json = unquote(dirty_json.replace('\'', '\"') - .replace('{', '{\"') - .replace(':\"', '\":\"') - .replace('\",', '\",\"')) - - known_devices = [x for x in json.loads(clean_json) if x] - - devices = {} - - for device in known_devices: - name = device.get('name') - mac = device.get('mac') - - if _MAC_REGEX.match(mac) or ',' in mac: - for mac_addr in mac.split(','): - if _MAC_REGEX.match(mac_addr): - devices[mac_addr] = name - else: - devices[mac] = name - - return devices diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index d0669ab4967..8c21e71bd30 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -17,7 +17,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify -REQUIREMENTS = ['locationsharinglib==2.0.7'] +REQUIREMENTS = ['locationsharinglib==2.0.11'] _LOGGER = logging.getLogger(__name__) @@ -26,18 +26,21 @@ ATTR_FULL_NAME = 'full_name' ATTR_LAST_SEEN = 'last_seen' ATTR_NICKNAME = 'nickname' +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' + CREDENTIALS_FILE = '.google_maps_location_sharing.cookies' MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_MAX_GPS_ACCURACY, default=100000): vol.Coerce(float), }) def setup_scanner(hass, config: ConfigType, see, discovery_info=None): - """Set up the scanner.""" + """Set up the Google Maps Location sharing scanner.""" scanner = GoogleMapsScanner(hass, config, see) return scanner.success_init @@ -53,6 +56,7 @@ class GoogleMapsScanner: self.see = see self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] + self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] try: self.service = Service(self.username, self.password, @@ -76,6 +80,14 @@ class GoogleMapsScanner: _LOGGER.warning("No location(s) shared with this account") return + if self.max_gps_accuracy is not None and \ + person.accuracy > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + person.nickname, self.max_gps_accuracy, + person.accuracy) + continue + attrs = { ATTR_ADDRESS: person.address, ATTR_FULL_NAME: person.full_name, diff --git a/homeassistant/components/device_tracker/huawei_router.py b/homeassistant/components/device_tracker/huawei_router.py index 804269e6228..f5e4fa8a714 100644 --- a/homeassistant/components/device_tracker/huawei_router.py +++ b/homeassistant/components/device_tracker/huawei_router.py @@ -85,8 +85,7 @@ class HuaweiDeviceScanner(DeviceScanner): active_clients = [client for client in data if client.state] self.last_results = active_clients - # pylint: disable=logging-not-lazy - _LOGGER.debug("Active clients: " + "\n" + _LOGGER.debug("Active clients: %s", "\n" .join((client.mac + " " + client.name) for client in active_clients)) return True diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 36dc1182a92..4b5e3d6333d 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -5,18 +5,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.keenetic_ndms2/ """ import logging -from collections import namedtuple -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME + CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME ) +REQUIREMENTS = ['ndms2_client==0.0.3'] + _LOGGER = logging.getLogger(__name__) # Interface name to track devices for. Most likely one will not need to @@ -25,11 +25,13 @@ _LOGGER = logging.getLogger(__name__) CONF_INTERFACE = 'interface' DEFAULT_INTERFACE = 'Home' +DEFAULT_PORT = 23 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, }) @@ -42,21 +44,22 @@ def get_scanner(_hass, config): return scanner if scanner.success_init else None -Device = namedtuple('Device', ['mac', 'name']) - - class KeeneticNDMS2DeviceScanner(DeviceScanner): """This class scans for devices using keenetic NDMS2 web interface.""" def __init__(self, config): """Initialize the scanner.""" + from ndms2_client import Client, TelnetConnection self.last_results = [] - self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] self._interface = config[CONF_INTERFACE] - self._username = config.get(CONF_USERNAME) - self._password = config.get(CONF_PASSWORD) + self._client = Client(TelnetConnection( + config.get(CONF_HOST), + config.get(CONF_PORT), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + )) self.success_init = self._update_info() _LOGGER.info("Scanner initialized") @@ -69,53 +72,32 @@ class KeeneticNDMS2DeviceScanner(DeviceScanner): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [result.name for result in self.last_results - if result.mac == device] + name = next(( + result.name for result in self.last_results + if result.mac == device), None) + return name - if filter_named: - return filter_named[0] - return None + def get_extra_attributes(self, device): + """Return the IP of the given device.""" + attributes = next(( + {'ip': result.ip} for result in self.last_results + if result.mac == device), {}) + return attributes def _update_info(self): """Get ARP from keenetic router.""" - _LOGGER.info("Fetching...") + _LOGGER.debug("Fetching devices from router...") - last_results = [] - - # doing a request + from ndms2_client import ConnectionException try: - from requests.auth import HTTPDigestAuth - res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth( - self._username, self._password - )) - except requests.exceptions.Timeout: - _LOGGER.error( - "Connection to the router timed out at URL %s", self._url) + self.last_results = [ + dev + for dev in self._client.get_devices() + if dev.interface == self._interface + ] + _LOGGER.debug("Successfully fetched data from router") + return True + + except ConnectionException: + _LOGGER.error("Error fetching data from router") return False - if res.status_code != 200: - _LOGGER.error( - "Connection failed with http code %s", res.status_code) - return False - try: - result = res.json() - except ValueError: - # If json decoder could not parse the response - _LOGGER.error("Failed to parse response from router") - return False - - # parsing response - for info in result: - if info.get('interface') != self._interface: - continue - mac = info.get('mac') - name = info.get('name') - # No address = no item :) - if mac is None: - continue - - last_results.append(Device(mac.upper(), name)) - - self.last_results = last_results - - _LOGGER.info("Request successful") - return True diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 0e48e3072b2..87be70b2040 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.4.0'] +REQUIREMENTS = ['pynetgear==0.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/ping.py b/homeassistant/components/device_tracker/ping.py index d09e1930d4f..f3492da9e80 100644 --- a/homeassistant/components/device_tracker/ping.py +++ b/homeassistant/components/device_tracker/ping.py @@ -38,7 +38,7 @@ class Host: self.dev_id = dev_id self._count = config[CONF_PING_COUNT] if sys.platform == 'win32': - self._ping_cmd = ['ping', '-n 1', '-w', '1000', self.ip_address] + self._ping_cmd = ['ping', '-n', '1', '-w', '1000', self.ip_address] else: self._ping_cmd = ['ping', '-n', '-q', '-c1', '-W1', self.ip_address] diff --git a/homeassistant/components/device_tracker/ritassist.py b/homeassistant/components/device_tracker/ritassist.py new file mode 100644 index 00000000000..9fc50de5062 --- /dev/null +++ b/homeassistant/components/device_tracker/ritassist.py @@ -0,0 +1,87 @@ +""" +Support for RitAssist Platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.ritassist/ +""" +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers.event import track_utc_time_change + +REQUIREMENTS = ['ritassist==0.5'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' +CONF_INCLUDE = 'include' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_INCLUDE, default=[]): + vol.All(cv.ensure_list, [cv.string]) +}) + + +def setup_scanner(hass, config: dict, see, discovery_info=None): + """Set up the DeviceScanner and check if login is valid.""" + scanner = RitAssistDeviceScanner(config, see) + if not scanner.login(hass): + _LOGGER.error('RitAssist authentication failed') + return False + return True + + +class RitAssistDeviceScanner: + """Define a scanner for the RitAssist platform.""" + + def __init__(self, config, see): + """Initialize RitAssistDeviceScanner.""" + from ritassist import API + + self._include = config.get(CONF_INCLUDE) + self._see = see + + self._api = API(config.get(CONF_CLIENT_ID), + config.get(CONF_CLIENT_SECRET), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)) + + def setup(self, hass): + """Setup a timer and start gathering devices.""" + self._refresh() + track_utc_time_change(hass, + lambda now: self._refresh(), + second=range(0, 60, 30)) + + def login(self, hass): + """Perform a login on the RitAssist API.""" + if self._api.login(): + self.setup(hass) + return True + return False + + def _refresh(self) -> None: + """Refresh device information from the platform.""" + try: + devices = self._api.get_devices() + + for device in devices: + if (not self._include or + device.license_plate in self._include): + self._see(dev_id=device.plate_as_id, + gps=(device.latitude, device.longitude), + attributes=device.state_attributes, + icon='mdi:car') + + except requests.exceptions.ConnectionError: + _LOGGER.error('ConnectionError: Could not connect to RitAssist') diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 6a849d0b05a..a9afc76e67c 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['pysnmp==4.4.4'] +REQUIREMENTS = ['pysnmp==4.4.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 78b891bae92..41cf3791256 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -13,7 +13,7 @@ import os import voluptuous as vol -from homeassistant import data_entry_flow +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START import homeassistant.helpers.config_validation as cv @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.5.0'] +REQUIREMENTS = ['netdisco==2.0.0'] DOMAIN = 'discovery' @@ -89,6 +89,7 @@ SERVICE_HANDLERS = { OPTIONAL_SERVICE_HANDLERS = { SERVICE_HOMEKIT: ('homekit_controller', None), + 'dlna_dmr': ('media_player', 'dlna_dmr'), } CONF_IGNORE = 'ignore' @@ -137,7 +138,7 @@ async def async_setup(hass, config): if service in CONFIG_ENTRY_HANDLERS: await hass.config_entries.flow.async_init( CONFIG_ENTRY_HANDLERS[service], - source=data_entry_flow.SOURCE_DISCOVERY, + context={'source': config_entries.SOURCE_DISCOVERY}, data=info ) return diff --git a/homeassistant/components/fan/dyson.py b/homeassistant/components/fan/dyson.py index fbe9ffc948c..3eb4646e6dc 100644 --- a/homeassistant/components/fan/dyson.py +++ b/homeassistant/components/fan/dyson.py @@ -18,6 +18,9 @@ _LOGGER = logging.getLogger(__name__) CONF_NIGHT_MODE = 'night_mode' +ATTR_IS_NIGHT_MODE = 'is_night_mode' +ATTR_IS_AUTO_MODE = 'is_auto_mode' + DEPENDENCIES = ['dyson'] DYSON_FAN_DEVICES = 'dyson_fan_devices' @@ -158,7 +161,7 @@ class DysonPureCoolLinkDevice(FanEntity): def is_on(self): """Return true if the entity is on.""" if self._device.state: - return self._device.state.fan_state == "FAN" + return self._device.state.fan_mode == "FAN" return False @property @@ -232,3 +235,11 @@ class DysonPureCoolLinkDevice(FanEntity): def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_OSCILLATE | SUPPORT_SET_SPEED + + @property + def device_state_attributes(self) -> dict: + """Return optional state attributes.""" + return { + ATTR_IS_NIGHT_MODE: self.is_night_mode, + ATTR_IS_AUTO_MODE: self.is_auto_mode + } diff --git a/homeassistant/components/fan/velbus.py b/homeassistant/components/fan/velbus.py deleted file mode 100644 index e8208d1c990..00000000000 --- a/homeassistant/components/fan/velbus.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -Support for Velbus platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/fan.velbus/ -""" -import asyncio -import logging -import voluptuous as vol - -from homeassistant.components.fan import ( - SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, - PLATFORM_SCHEMA) -from homeassistant.components.velbus import DOMAIN -from homeassistant.const import CONF_NAME, CONF_DEVICES, STATE_OFF -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['velbus'] - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ - { - vol.Required('module'): cv.positive_int, - vol.Required('channel_low'): cv.positive_int, - vol.Required('channel_medium'): cv.positive_int, - vol.Required('channel_high'): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - } - ]) -}) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Fans.""" - velbus = hass.data[DOMAIN] - add_devices(VelbusFan(fan, velbus) for fan in config[CONF_DEVICES]) - - -class VelbusFan(FanEntity): - """Representation of a Velbus Fan.""" - - def __init__(self, fan, velbus): - """Initialize a Velbus light.""" - self._velbus = velbus - self._name = fan[CONF_NAME] - self._module = fan['module'] - self._channel_low = fan['channel_low'] - self._channel_medium = fan['channel_medium'] - self._channel_high = fan['channel_high'] - self._channels = [self._channel_low, self._channel_medium, - self._channel_high] - self._channels_state = [False, False, False] - self._speed = STATE_OFF - - @asyncio.coroutine - def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - def _init_velbus(): - """Initialize Velbus on startup.""" - self._velbus.subscribe(self._on_message) - self.get_status() - - yield from self.hass.async_add_job(_init_velbus) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.RelayStatusMessage) and \ - message.address == self._module and \ - message.channel in self._channels: - if message.channel == self._channel_low: - self._channels_state[0] = message.is_on() - elif message.channel == self._channel_medium: - self._channels_state[1] = message.is_on() - elif message.channel == self._channel_high: - self._channels_state[2] = message.is_on() - self._calculate_speed() - self.schedule_update_ha_state() - - def _calculate_speed(self): - if self._is_off(): - self._speed = STATE_OFF - elif self._is_low(): - self._speed = SPEED_LOW - elif self._is_medium(): - self._speed = SPEED_MEDIUM - elif self._is_high(): - self._speed = SPEED_HIGH - - def _is_off(self): - return self._channels_state[0] is False and \ - self._channels_state[1] is False and \ - self._channels_state[2] is False - - def _is_low(self): - return self._channels_state[0] is True and \ - self._channels_state[1] is False and \ - self._channels_state[2] is False - - def _is_medium(self): - return self._channels_state[0] is True and \ - self._channels_state[1] is True and \ - self._channels_state[2] is False - - def _is_high(self): - return self._channels_state[0] is True and \ - self._channels_state[1] is False and \ - self._channels_state[2] is True - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def speed(self): - """Return the current speed.""" - return self._speed - - @property - def speed_list(self): - """Get the list of available speeds.""" - return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - - def turn_on(self, speed=None, **kwargs): - """Turn on the entity.""" - if speed is None: - speed = SPEED_MEDIUM - self.set_speed(speed) - - def turn_off(self, **kwargs): - """Turn off the entity.""" - self.set_speed(STATE_OFF) - - def set_speed(self, speed): - """Set the speed of the fan.""" - channels_off = [] - channels_on = [] - if speed == STATE_OFF: - channels_off = self._channels - elif speed == SPEED_LOW: - channels_off = [self._channel_medium, self._channel_high] - channels_on = [self._channel_low] - elif speed == SPEED_MEDIUM: - channels_off = [self._channel_high] - channels_on = [self._channel_low, self._channel_medium] - elif speed == SPEED_HIGH: - channels_off = [self._channel_medium] - channels_on = [self._channel_low, self._channel_high] - for channel in channels_off: - self._relay_off(channel) - for channel in channels_on: - self._relay_on(channel) - self.schedule_update_ha_state() - - def _relay_on(self, channel): - import velbus - message = velbus.SwitchRelayOnMessage() - message.set_defaults(self._module) - message.relay_channels = [channel] - self._velbus.send(message) - - def _relay_off(self, channel): - import velbus - message = velbus.SwitchRelayOffMessage() - message.set_defaults(self._module) - message.relay_channels = [channel] - self._velbus.send(message) - - def get_status(self): - """Retrieve current status.""" - import velbus - message = velbus.ModuleStatusRequestMessage() - message.set_defaults(self._module) - message.channels = self._channels - self._velbus.send(message) - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_SET_SPEED diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 540341c68f2..40fb6056684 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180804.0'] +REQUIREMENTS = ['home-assistant-frontend==20180816.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', @@ -249,6 +249,7 @@ async def async_setup(hass, config): index_view = IndexView(repo_path, js_version, hass.auth.active) hass.http.register_view(index_view) + hass.http.register_view(AuthorizeView(repo_path, js_version)) @callback def async_finalize_panel(panel): @@ -334,6 +335,35 @@ def _async_setup_themes(hass, themes): hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) +class AuthorizeView(HomeAssistantView): + """Serve the frontend.""" + + url = '/auth/authorize' + name = 'auth:authorize' + requires_auth = False + + def __init__(self, repo_path, js_option): + """Initialize the frontend view.""" + self.repo_path = repo_path + self.js_option = js_option + + async def get(self, request: web.Request): + """Redirect to the authorize page.""" + latest = self.repo_path is not None or \ + _is_latest(self.js_option, request) + + if latest: + location = '/frontend_latest/authorize.html' + else: + location = '/frontend_es5/authorize.html' + + location += '?{}'.format(request.query_string) + + return web.Response(status=302, headers={ + 'location': location + }) + + class IndexView(HomeAssistantView): """Serve the frontend.""" diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 13c486533d9..e0356017e3e 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -178,7 +178,7 @@ def async_setup(hass, config): refresh_token = None if 'hassio_user' in data: user = yield from hass.auth.async_get_user(data['hassio_user']) - if user: + if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] if refresh_token is None: diff --git a/homeassistant/components/homematicip_cloud/.translations/de.json b/homeassistant/components/homematicip_cloud/.translations/de.json index 8e4130a3251..61a9bd6eb40 100644 --- a/homeassistant/components/homematicip_cloud/.translations/de.json +++ b/homeassistant/components/homematicip_cloud/.translations/de.json @@ -17,9 +17,11 @@ "hapid": "Accesspoint ID (SGTIN)", "name": "Name (optional, wird als Pr\u00e4fix f\u00fcr alle Ger\u00e4te verwendet)", "pin": "PIN Code (optional)" - } + }, + "title": "HometicIP Accesspoint ausw\u00e4hlen" }, "link": { + "description": "Dr\u00fccken Sie den blauen Taster auf dem Accesspoint, sowie den Senden Button um HomematicIP mit Home Assistant zu verbinden.\n\n![Position des Tasters auf dem AP](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Verkn\u00fcpfe den Accesspoint" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/no.json b/homeassistant/components/homematicip_cloud/.translations/no.json index 7e164abd3bb..650c921af31 100644 --- a/homeassistant/components/homematicip_cloud/.translations/no.json +++ b/homeassistant/components/homematicip_cloud/.translations/no.json @@ -22,7 +22,7 @@ }, "link": { "description": "Trykk p\u00e5 den bl\u00e5 knappen p\u00e5 tilgangspunktet og send knappen for \u00e5 registrere HomematicIP med Home Assistant. \n\n![Plassering av knapp p\u00e5 bridge](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "Link Tilgangspunkt" + "title": "Link tilgangspunkt" } }, "title": "HomematicIP Sky" diff --git a/homeassistant/components/homematicip_cloud/.translations/pt.json b/homeassistant/components/homematicip_cloud/.translations/pt.json index ef742e2ce5e..2266e83ac44 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pt.json +++ b/homeassistant/components/homematicip_cloud/.translations/pt.json @@ -21,7 +21,7 @@ "title": "Escolher ponto de acesso HomematicIP" }, "link": { - "description": "Pressione o bot\u00e3o azul no accesspoint e o bot\u00e3o enviar para registrar HomematicIP com Home Assistant.\n\n! [Localiza\u00e7\u00e3o do bot\u00e3o na ponte] (/ static/images/config_flows/config_homematicip_cloud.png)", + "description": "Pressione o bot\u00e3o azul no ponto de acesso e o bot\u00e3o enviar para registrar HomematicIP com o Home Assistant.\n\n![Localiza\u00e7\u00e3o do bot\u00e3o na ponte](/ static/images/config_flows/config_homematicip_cloud.png)", "title": "Associar ponto de acesso" } }, diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index b9266322978..f2cc8f443ac 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -10,6 +10,7 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries from .const import ( DOMAIN, HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_NAME, @@ -41,7 +42,8 @@ async def async_setup(hass, config): for conf in accesspoints: if conf[CONF_ACCESSPOINT] not in configured_haps(hass): hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, source='import', data={ + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ HMIPC_HAPID: conf[CONF_ACCESSPOINT], HMIPC_AUTHTOKEN: conf[CONF_AUTHTOKEN], HMIPC_NAME: conf[CONF_NAME], diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 3be89172e27..78970031d11 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -27,6 +27,10 @@ class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler): """Initialize HomematicIP Cloud config flow.""" self.auth = None + 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 flow start.""" errors = {} diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 42629f752ad..9ba977f92f5 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -8,6 +8,7 @@ from ipaddress import ip_network import logging import os import ssl +from typing import Optional from aiohttp import web from aiohttp.web_exceptions import HTTPMovedPermanently @@ -16,7 +17,6 @@ import voluptuous as vol from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVER_PORT) import homeassistant.helpers.config_validation as cv -import homeassistant.remote as rem import homeassistant.util as hass_util from homeassistant.util.logging import HideSensitiveDataFilter from homeassistant.util import ssl as ssl_util @@ -49,6 +49,10 @@ CONF_TRUSTED_PROXIES = 'trusted_proxies' CONF_TRUSTED_NETWORKS = 'trusted_networks' CONF_LOGIN_ATTEMPTS_THRESHOLD = 'login_attempts_threshold' CONF_IP_BAN_ENABLED = 'ip_ban_enabled' +CONF_SSL_PROFILE = 'ssl_profile' + +SSL_MODERN = 'modern' +SSL_INTERMEDIATE = 'intermediate' _LOGGER = logging.getLogger(__name__) @@ -66,15 +70,17 @@ HTTP_SCHEMA = vol.Schema({ vol.Optional(CONF_SSL_KEY): cv.isfile, vol.Optional(CONF_CORS_ORIGINS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_USE_X_FORWARDED_FOR, default=False): cv.boolean, - vol.Optional(CONF_TRUSTED_PROXIES, default=[]): + vol.Inclusive(CONF_USE_X_FORWARDED_FOR, 'proxy'): cv.boolean, + vol.Inclusive(CONF_TRUSTED_PROXIES, 'proxy'): vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_TRUSTED_NETWORKS, default=[]): vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), - vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean + vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): + vol.In([SSL_INTERMEDIATE, SSL_MODERN]), }) CONFIG_SCHEMA = vol.Schema({ @@ -82,6 +88,28 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +class ApiConfig: + """Configuration settings for API server.""" + + def __init__(self, host: str, port: Optional[int] = SERVER_PORT, + use_ssl: bool = False, + api_password: Optional[str] = None) -> None: + """Initialize a new API config object.""" + self.host = host + self.port = port + self.api_password = api_password + + if host.startswith(("http://", "https://")): + self.base_url = host + elif use_ssl: + self.base_url = "https://{}".format(host) + else: + self.base_url = "http://{}".format(host) + + if port is not None: + self.base_url += ':{}'.format(port) + + async def async_setup(hass, config): """Set up the HTTP API and debug interface.""" conf = config.get(DOMAIN) @@ -96,11 +124,12 @@ async def async_setup(hass, config): ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] - use_x_forwarded_for = conf[CONF_USE_X_FORWARDED_FOR] - trusted_proxies = conf[CONF_TRUSTED_PROXIES] + use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) + trusted_proxies = conf.get(CONF_TRUSTED_PROXIES, []) trusted_networks = conf[CONF_TRUSTED_NETWORKS] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] + ssl_profile = conf[CONF_SSL_PROFILE] if api_password is not None: logging.getLogger('aiohttp.access').addFilter( @@ -119,7 +148,8 @@ async def async_setup(hass, config): trusted_proxies=trusted_proxies, trusted_networks=trusted_networks, login_threshold=login_threshold, - is_ban_enabled=is_ban_enabled + is_ban_enabled=is_ban_enabled, + ssl_profile=ssl_profile, ) async def stop_server(event): @@ -146,8 +176,8 @@ async def async_setup(hass, config): host = hass_util.get_local_ip() port = server_port - hass.config.api = rem.API(host, api_password, port, - ssl_certificate is not None) + hass.config.api = ApiConfig(host, port, ssl_certificate is not None, + api_password) return True @@ -159,7 +189,7 @@ class HomeAssistantHTTP: ssl_certificate, ssl_peer_certificate, ssl_key, server_host, server_port, cors_origins, use_x_forwarded_for, trusted_proxies, trusted_networks, - login_threshold, is_ban_enabled): + login_threshold, is_ban_enabled, ssl_profile): """Initialize the HTTP Home Assistant server.""" app = self.app = web.Application( middlewares=[staticresource_middleware]) @@ -199,6 +229,7 @@ class HomeAssistantHTTP: self.server_host = server_host self.server_port = server_port self.is_ban_enabled = is_ban_enabled + self.ssl_profile = ssl_profile self._handler = None self.server = None @@ -285,7 +316,10 @@ class HomeAssistantHTTP: if self.ssl_certificate: try: - context = ssl_util.server_context() + if self.ssl_profile == SSL_INTERMEDIATE: + context = ssl_util.server_context_intermediate() + else: + context = ssl_util.server_context_modern() context.load_cert_chain(self.ssl_certificate, self.ssl_key) except OSError as error: _LOGGER.error("Could not read SSL certificate from %s: %s", diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 77621e3bc7c..d01d1b50c5a 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -106,11 +106,11 @@ async def async_validate_auth_header(request, api_password=None): if auth_type == 'Bearer': hass = request.app['hass'] - access_token = hass.auth.async_get_access_token(auth_val) - if access_token is None: + refresh_token = await hass.auth.async_validate_access_token(auth_val) + if refresh_token is None: return False - request['hass_user'] = access_token.refresh_token.user + request['hass_user'] = refresh_token.user return True if auth_type == 'Basic' and api_password is not None: diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json index dc0968dc88a..a0bd50d8514 100644 --- a/homeassistant/components/hue/.translations/de.json +++ b/homeassistant/components/hue/.translations/de.json @@ -24,6 +24,6 @@ "title": "Hub verbinden" } }, - "title": "" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ro.json b/homeassistant/components/hue/.translations/ro.json index 91541edcc7d..69cee1198d3 100644 --- a/homeassistant/components/hue/.translations/ro.json +++ b/homeassistant/components/hue/.translations/ro.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "all_configured": "Toate pun\u021bile Philips Hue sunt deja configurate", + "discover_timeout": "Imposibil de descoperit podurile Hue" + }, "error": { "linking": "A ap\u0103rut o eroare de leg\u0103tur\u0103 necunoscut\u0103.", "register_failed": "Nu a reu\u0219it \u00eenregistrarea, \u00eencerca\u021bi din nou" diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index dbd86ef31f3..c04380e1303 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -9,7 +9,7 @@ import logging import voluptuous as vol -from homeassistant import data_entry_flow +from homeassistant import config_entries from homeassistant.const import CONF_FILENAME, CONF_HOST from homeassistant.helpers import aiohttp_client, config_validation as cv @@ -108,7 +108,8 @@ async def async_setup(hass, config): # deadlock: creating a config entry will set up the component but the # setup would block till the entry is created! hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, source=data_entry_flow.SOURCE_IMPORT, data={ + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ 'host': bridge_conf[CONF_HOST], 'path': bridge_conf[CONF_FILENAME], } diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index b7cf0e1de07..874c18aaa7e 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -51,7 +51,8 @@ class HueBridge: # linking procedure. When linking succeeds, it will remove the # old config entry. hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, source='import', data={ + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ 'host': host, } )) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index a7fe3ff04e0..49ebbdaabf5 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -50,6 +50,10 @@ class HueFlowHandler(data_entry_flow.FlowHandler): """Initialize the Hue flow.""" self.host = None + 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 flow start.""" from aiohue.discovery import discover_nupnp diff --git a/homeassistant/components/image_processing/facebox.py b/homeassistant/components/image_processing/facebox.py index c863f804513..e5ce0b825d0 100644 --- a/homeassistant/components/image_processing/facebox.py +++ b/homeassistant/components/image_processing/facebox.py @@ -17,25 +17,29 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingFaceEntity, ATTR_CONFIDENCE, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME, DOMAIN) -from homeassistant.const import (CONF_IP_ADDRESS, CONF_PORT) +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, + HTTP_BAD_REQUEST, HTTP_OK, HTTP_UNAUTHORIZED) _LOGGER = logging.getLogger(__name__) ATTR_BOUNDING_BOX = 'bounding_box' ATTR_CLASSIFIER = 'classifier' ATTR_IMAGE_ID = 'image_id' +ATTR_ID = 'id' ATTR_MATCHED = 'matched' +FACEBOX_NAME = 'name' CLASSIFIER = 'facebox' DATA_FACEBOX = 'facebox_classifiers' -EVENT_CLASSIFIER_TEACH = 'image_processing.teach_classifier' FILE_PATH = 'file_path' SERVICE_TEACH_FACE = 'facebox_teach_face' -TIMEOUT = 9 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_IP_ADDRESS): cv.string, vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, }) SERVICE_TEACH_SCHEMA = vol.Schema({ @@ -45,6 +49,26 @@ SERVICE_TEACH_SCHEMA = vol.Schema({ }) +def check_box_health(url, username, password): + """Check the health of the classifier and return its id if healthy.""" + kwargs = {} + if username: + kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password) + try: + response = requests.get( + url, + **kwargs + ) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + return None + if response.status_code == HTTP_OK: + return response.json()['hostname'] + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + return None + + def encode_image(image): """base64 encode an image stream.""" base64_img = base64.b64encode(image).decode('ascii') @@ -63,10 +87,10 @@ def parse_faces(api_faces): for entry in api_faces: face = {} if entry['matched']: # This data is only in matched faces. - face[ATTR_NAME] = entry['name'] + face[FACEBOX_NAME] = entry['name'] face[ATTR_IMAGE_ID] = entry['id'] else: # Lets be explicit. - face[ATTR_NAME] = None + face[FACEBOX_NAME] = None face[ATTR_IMAGE_ID] = None face[ATTR_CONFIDENCE] = round(100.0*entry['confidence'], 2) face[ATTR_MATCHED] = entry['matched'] @@ -75,17 +99,46 @@ def parse_faces(api_faces): return known_faces -def post_image(url, image): +def post_image(url, image, username, password): """Post an image to the classifier.""" + kwargs = {} + if username: + kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password) try: response = requests.post( url, json={"base64": encode_image(image)}, - timeout=TIMEOUT + **kwargs ) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + return None return response except requests.exceptions.ConnectionError: _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + return None + + +def teach_file(url, name, file_path, username, password): + """Teach the classifier a name associated with a file.""" + kwargs = {} + if username: + kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password) + try: + with open(file_path, 'rb') as open_file: + response = requests.post( + url, + data={FACEBOX_NAME: name, ATTR_ID: file_path}, + files={'file': open_file}, + **kwargs + ) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + elif response.status_code == HTTP_BAD_REQUEST: + _LOGGER.error("%s teaching of file %s failed with message:%s", + CLASSIFIER, file_path, response.text) + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) def valid_file_path(file_path): @@ -104,13 +157,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if DATA_FACEBOX not in hass.data: hass.data[DATA_FACEBOX] = [] + ip_address = config[CONF_IP_ADDRESS] + port = config[CONF_PORT] + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + url_health = "http://{}:{}/healthz".format(ip_address, port) + hostname = check_box_health(url_health, username, password) + if hostname is None: + return + entities = [] for camera in config[CONF_SOURCE]: facebox = FaceClassifyEntity( - config[CONF_IP_ADDRESS], - config[CONF_PORT], - camera[CONF_ENTITY_ID], - camera.get(CONF_NAME)) + ip_address, port, username, password, hostname, + camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) entities.append(facebox) hass.data[DATA_FACEBOX].append(facebox) add_devices(entities) @@ -129,33 +189,37 @@ def setup_platform(hass, config, add_devices, discovery_info=None): classifier.teach(name, file_path) hass.services.register( - DOMAIN, - SERVICE_TEACH_FACE, - service_handle, + DOMAIN, SERVICE_TEACH_FACE, service_handle, schema=SERVICE_TEACH_SCHEMA) class FaceClassifyEntity(ImageProcessingFaceEntity): """Perform a face classification.""" - def __init__(self, ip, port, camera_entity, name=None): + def __init__(self, ip_address, port, username, password, hostname, + camera_entity, name=None): """Init with the API key and model id.""" super().__init__() - self._url_check = "http://{}:{}/{}/check".format(ip, port, CLASSIFIER) - self._url_teach = "http://{}:{}/{}/teach".format(ip, port, CLASSIFIER) + self._url_check = "http://{}:{}/{}/check".format( + ip_address, port, CLASSIFIER) + self._url_teach = "http://{}:{}/{}/teach".format( + ip_address, port, CLASSIFIER) + self._username = username + self._password = password + self._hostname = hostname self._camera = camera_entity if name: self._name = name else: camera_name = split_entity_id(camera_entity)[1] - self._name = "{} {}".format( - CLASSIFIER, camera_name) + self._name = "{} {}".format(CLASSIFIER, camera_name) self._matched = {} def process_image(self, image): """Process an image.""" - response = post_image(self._url_check, image) - if response is not None: + response = post_image( + self._url_check, image, self._username, self._password) + if response: response_json = response.json() if response_json['success']: total_faces = response_json['facesCount'] @@ -173,34 +237,8 @@ class FaceClassifyEntity(ImageProcessingFaceEntity): if (not self.hass.config.is_allowed_path(file_path) or not valid_file_path(file_path)): return - with open(file_path, 'rb') as open_file: - response = requests.post( - self._url_teach, - data={ATTR_NAME: name, 'id': file_path}, - files={'file': open_file}) - - if response.status_code == 200: - self.hass.bus.fire( - EVENT_CLASSIFIER_TEACH, { - ATTR_CLASSIFIER: CLASSIFIER, - ATTR_NAME: name, - FILE_PATH: file_path, - 'success': True, - 'message': None - }) - - elif response.status_code == 400: - _LOGGER.warning( - "%s teaching of file %s failed with message:%s", - CLASSIFIER, file_path, response.text) - self.hass.bus.fire( - EVENT_CLASSIFIER_TEACH, { - ATTR_CLASSIFIER: CLASSIFIER, - ATTR_NAME: name, - FILE_PATH: file_path, - 'success': False, - 'message': response.text - }) + teach_file( + self._url_teach, name, file_path, self._username, self._password) @property def camera_entity(self): @@ -218,4 +256,5 @@ class FaceClassifyEntity(ImageProcessingFaceEntity): return { 'matched_faces': self._matched, 'total_matched_faces': len(self._matched), + 'hostname': self._hostname } diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index f372d308927..20160edf806 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -4,9 +4,9 @@ Support for deCONZ light. For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.deconz/ """ -from homeassistant.components.deconz import ( - DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB) -from homeassistant.components.deconz.const import CONF_ALLOW_DECONZ_GROUPS +from homeassistant.components.deconz.const import ( + CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DATA_DECONZ, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, SWITCH_TYPES) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, @@ -32,7 +32,8 @@ async def async_setup_entry(hass, config_entry, async_add_devices): """Add light from deCONZ.""" entities = [] for light in lights: - entities.append(DeconzLight(light)) + if light.type not in SWITCH_TYPES: + entities.append(DeconzLight(light)) async_add_devices(entities, True) hass.data[DATA_DECONZ_UNSUB].append( @@ -189,3 +190,12 @@ class DeconzLight(Light): del data['on'] await self._light.async_set_state(data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + attributes['is_deconz_group'] = self._light.type == 'LightGroup' + if self._light.type == 'LightGroup': + attributes['all_on'] = self._light.all_on + return attributes diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py index f9ffbb4e0bf..b2fdd36abe7 100644 --- a/homeassistant/components/light/group.py +++ b/homeassistant/components/light/group.py @@ -254,8 +254,6 @@ def _mean_tuple(*args): return tuple(sum(l) / len(l) for l in zip(*args)) -# https://github.com/PyCQA/pylint/issues/1831 -# pylint: disable=bad-whitespace def _reduce_attribute(states: List[State], key: str, default: Optional[Any] = None, diff --git a/homeassistant/components/light/velbus.py b/homeassistant/components/light/velbus.py deleted file mode 100644 index 8a02b36b75f..00000000000 --- a/homeassistant/components/light/velbus.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Support for Velbus lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.velbus/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_DEVICES -from homeassistant.components.light import Light, PLATFORM_SCHEMA -from homeassistant.components.velbus import DOMAIN -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['velbus'] - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ - { - vol.Required('module'): cv.positive_int, - vol.Required('channel'): cv.positive_int, - vol.Required(CONF_NAME): cv.string - } - ]) -}) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Lights.""" - velbus = hass.data[DOMAIN] - add_devices(VelbusLight(light, velbus) for light in config[CONF_DEVICES]) - - -class VelbusLight(Light): - """Representation of a Velbus Light.""" - - def __init__(self, light, velbus): - """Initialize a Velbus light.""" - self._velbus = velbus - self._name = light[CONF_NAME] - self._module = light['module'] - self._channel = light['channel'] - self._state = False - - @asyncio.coroutine - def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - def _init_velbus(): - """Initialize Velbus on startup.""" - self._velbus.subscribe(self._on_message) - self.get_status() - - yield from self.hass.async_add_job(_init_velbus) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.RelayStatusMessage) and \ - message.address == self._module and \ - message.channel == self._channel: - self._state = message.is_on() - self.schedule_update_ha_state() - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def is_on(self): - """Return true if the light is on.""" - return self._state - - def turn_on(self, **kwargs): - """Instruct the light to turn on.""" - import velbus - message = velbus.SwitchRelayOnMessage() - message.set_defaults(self._module) - message.relay_channels = [self._channel] - self._velbus.send(message) - - def turn_off(self, **kwargs): - """Instruct the light to turn off.""" - import velbus - message = velbus.SwitchRelayOffMessage() - message.set_defaults(self._module) - message.relay_channels = [self._channel] - self._velbus.send(message) - - def get_status(self): - """Retrieve current status.""" - import velbus - message = velbus.ModuleStatusRequestMessage() - message.set_defaults(self._module) - message.channels = [self._channel] - self._velbus.send(message) diff --git a/homeassistant/components/map.py b/homeassistant/components/map.py index 30cb00af69e..c0184239a1a 100644 --- a/homeassistant/components/map.py +++ b/homeassistant/components/map.py @@ -10,5 +10,5 @@ DOMAIN = 'map' async def async_setup(hass, config): """Register the built-in map panel.""" await hass.components.frontend.async_register_built_in_panel( - 'map', 'map', 'mdi:account-location') + 'map', 'map', 'hass:account-location') return True diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 029d10ea00a..793d33e52fa 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.07.29'] +REQUIREMENTS = ['youtube_dl==2018.08.04'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py new file mode 100644 index 00000000000..c40e3ed0ca9 --- /dev/null +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -0,0 +1,400 @@ +# -*- coding: utf-8 -*- +""" +Support for DLNA DMR (Device Media Renderer). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.dlna_dmr/ +""" + +import asyncio +import functools +import logging +from datetime import datetime + +import aiohttp +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.media_player import ( + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + MediaPlayerDevice, + PLATFORM_SCHEMA) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + CONF_URL, CONF_NAME, + STATE_OFF, STATE_ON, STATE_IDLE, STATE_PLAYING, STATE_PAUSED) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import get_local_ip + + +DLNA_DMR_DATA = 'dlna_dmr' + +REQUIREMENTS = [ + 'async-upnp-client==0.12.3', +] + +DEFAULT_NAME = 'DLNA Digital Media Renderer' +DEFAULT_LISTEN_PORT = 8301 + +CONF_LISTEN_IP = 'listen_ip' +CONF_LISTEN_PORT = 'listen_port' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LISTEN_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +HOME_ASSISTANT_UPNP_CLASS_MAPPING = { + 'music': 'object.item.audioItem', + 'tvshow': 'object.item.videoItem', + 'video': 'object.item.videoItem', + 'episode': 'object.item.videoItem', + 'channel': 'object.item.videoItem', + 'playlist': 'object.item.playlist', +} +HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING = { + 'music': 'audio/*', + 'tvshow': 'video/*', + 'video': 'video/*', + 'episode': 'video/*', + 'channel': 'video/*', + 'playlist': 'playlist/*', +} + +_LOGGER = logging.getLogger(__name__) + + +def catch_request_errors(): + """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" + def call_wrapper(func): + """Call wrapper for decorator.""" + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" + try: + return func(self, *args, **kwargs) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Error during call %s", func.__name__) + + return wrapper + + return call_wrapper + + +async def async_start_event_handler(hass, server_host, server_port, requester): + """Register notify view.""" + hass_data = hass.data[DLNA_DMR_DATA] + if 'event_handler' in hass_data: + return hass_data['event_handler'] + + # start event handler + from async_upnp_client.aiohttp import AiohttpNotifyServer + server = AiohttpNotifyServer(requester, + server_port, + server_host, + hass.loop) + await server.start_server() + _LOGGER.info('UPNP/DLNA event handler listening on: %s', + server.callback_url) + hass_data['notify_server'] = server + hass_data['event_handler'] = server.event_handler + + # register for graceful shutdown + async def async_stop_server(event): + """Stop server.""" + _LOGGER.debug('Stopping UPNP/DLNA event handler') + await server.stop_server() + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server) + + return hass_data['event_handler'] + + +async def async_setup_platform(hass: HomeAssistant, + config, + async_add_devices, + discovery_info=None): + """Set up DLNA DMR platform.""" + if config.get(CONF_URL) is not None: + url = config[CONF_URL] + name = config.get(CONF_NAME) + elif discovery_info is not None: + url = discovery_info['ssdp_description'] + name = discovery_info.get('name') + + if DLNA_DMR_DATA not in hass.data: + hass.data[DLNA_DMR_DATA] = {} + + if 'lock' not in hass.data[DLNA_DMR_DATA]: + hass.data[DLNA_DMR_DATA]['lock'] = asyncio.Lock() + + # build upnp/aiohttp requester + from async_upnp_client.aiohttp import AiohttpSessionRequester + session = async_get_clientsession(hass) + requester = AiohttpSessionRequester(session, True) + + # ensure event handler has been started + with await hass.data[DLNA_DMR_DATA]['lock']: + server_host = config.get(CONF_LISTEN_IP) + if server_host is None: + server_host = get_local_ip() + server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) + event_handler = await async_start_event_handler(hass, + server_host, + server_port, + requester) + + # create upnp device + from async_upnp_client import UpnpFactory + factory = UpnpFactory(requester, disable_state_variable_validation=True) + try: + upnp_device = await factory.async_create_device(url) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise PlatformNotReady() + + # wrap with DmrDevice + from async_upnp_client.dlna import DmrDevice + dlna_device = DmrDevice(upnp_device, event_handler) + + # create our own device + device = DlnaDmrDevice(dlna_device, name) + _LOGGER.debug("Adding device: %s", device) + async_add_devices([device], True) + + +class DlnaDmrDevice(MediaPlayerDevice): + """Representation of a DLNA DMR device.""" + + def __init__(self, dmr_device, name=None): + """Initializer.""" + self._device = dmr_device + self._name = name + + self._available = False + self._subscription_renew_time = None + + async def async_added_to_hass(self): + """Callback when added.""" + self._device.on_event = self._on_event + + # register unsubscribe on stop + bus = self.hass.bus + bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + self._async_on_hass_stop) + + @property + def available(self): + """Device is available.""" + return self._available + + async def _async_on_hass_stop(self, event): + """Event handler on HASS stop.""" + with await self.hass.data[DLNA_DMR_DATA]['lock']: + await self._device.async_unsubscribe_services() + + async def async_update(self): + """Retrieve the latest data.""" + was_available = self._available + + try: + await self._device.async_update() + self._available = True + except (asyncio.TimeoutError, aiohttp.ClientError): + self._available = False + _LOGGER.debug("Device unavailable") + return + + # do we need to (re-)subscribe? + now = datetime.now() + should_renew = self._subscription_renew_time and \ + now >= self._subscription_renew_time + if should_renew or \ + not was_available and self._available: + try: + timeout = await self._device.async_subscribe_services() + self._subscription_renew_time = datetime.now() + timeout / 2 + except (asyncio.TimeoutError, aiohttp.ClientError): + self._available = False + _LOGGER.debug("Could not (re)subscribe") + + def _on_event(self, service, state_variables): + """State variable(s) changed, let home-assistant know.""" + self.schedule_update_ha_state() + + @property + def supported_features(self): + """Flag media player features that are supported.""" + supported_features = 0 + + if self._device.has_volume_level: + supported_features |= SUPPORT_VOLUME_SET + if self._device.has_volume_mute: + supported_features |= SUPPORT_VOLUME_MUTE + if self._device.has_play: + supported_features |= SUPPORT_PLAY + if self._device.has_pause: + supported_features |= SUPPORT_PAUSE + if self._device.has_stop: + supported_features |= SUPPORT_STOP + if self._device.has_previous: + supported_features |= SUPPORT_PREVIOUS_TRACK + if self._device.has_next: + supported_features |= SUPPORT_NEXT_TRACK + if self._device.has_play_media: + supported_features |= SUPPORT_PLAY_MEDIA + + return supported_features + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._device.volume_level + + @catch_request_errors() + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._device.async_set_volume_level(volume) + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._device.is_volume_muted + + @catch_request_errors() + async def async_mute_volume(self, mute): + """Mute the volume.""" + desired_mute = bool(mute) + await self._device.async_mute_volume(desired_mute) + + @catch_request_errors() + async def async_media_pause(self): + """Send pause command.""" + if not self._device.can_pause: + _LOGGER.debug('Cannot do Pause') + return + + await self._device.async_pause() + + @catch_request_errors() + async def async_media_play(self): + """Send play command.""" + if not self._device.can_play: + _LOGGER.debug('Cannot do Play') + return + + await self._device.async_play() + + @catch_request_errors() + async def async_media_stop(self): + """Send stop command.""" + if not self._device.can_stop: + _LOGGER.debug('Cannot do Stop') + return + + await self._device.async_stop() + + @catch_request_errors() + async def async_play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + title = "Home Assistant" + mime_type = HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING[media_type] + upnp_class = HOME_ASSISTANT_UPNP_CLASS_MAPPING[media_type] + + # stop current playing media + if self._device.can_stop: + await self.async_media_stop() + + # queue media + await self._device.async_set_transport_uri(media_id, + title, + mime_type, + upnp_class) + await self._device.async_wait_for_can_play() + + # if already playing, no need to call Play + from async_upnp_client import dlna + if self._device.state == dlna.STATE_PLAYING: + return + + # play it + await self.async_media_play() + + @catch_request_errors() + async def async_media_previous_track(self): + """Send previous track command.""" + if not self._device.can_previous: + _LOGGER.debug('Cannot do Previous') + return + + await self._device.async_previous() + + @catch_request_errors() + async def async_media_next_track(self): + """Send next track command.""" + if not self._device.can_next: + _LOGGER.debug('Cannot do Next') + return + + await self._device.async_next() + + @property + def media_title(self): + """Title of current playing media.""" + return self._device.media_title + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._device.media_image_url + + @property + def state(self): + """State of the player.""" + if not self._available: + return STATE_OFF + + from async_upnp_client import dlna + if self._device.state is None: + return STATE_ON + if self._device.state == dlna.STATE_PLAYING: + return STATE_PLAYING + if self._device.state == dlna.STATE_PAUSED: + return STATE_PAUSED + + return STATE_IDLE + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._device.media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._device.media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._device.media_position_updated_at + + @property + def name(self) -> str: + """Return the name of the device.""" + if self._name: + return self._name + return self._device.name + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._device.udn diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 8758e969db1..08de2d00835 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -160,6 +160,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if DATA_KODI not in hass.data: hass.data[DATA_KODI] = dict() + unique_id = None # Is this a manual configuration? if discovery_info is None: name = config.get(CONF_NAME) @@ -175,6 +176,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): tcp_port = DEFAULT_TCP_PORT encryption = DEFAULT_PROXY_SSL websocket = DEFAULT_ENABLE_WEBSOCKET + properties = discovery_info.get('properties') + if properties is not None: + unique_id = properties.get('uuid', None) # Only add a device once, so discovered devices do not override manual # config. @@ -182,6 +186,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if ip_addr in hass.data[DATA_KODI]: return + # If we got an unique id, check that it does not exist already. + # This is necessary as netdisco does not deterministally return the same + # advertisement when the service is offered over multiple IP addresses. + if unique_id is not None: + for device in hass.data[DATA_KODI].values(): + if device.unique_id == unique_id: + return + entity = KodiDevice( hass, name=name, @@ -190,7 +202,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): password=config.get(CONF_PASSWORD), turn_on_action=config.get(CONF_TURN_ON_ACTION), turn_off_action=config.get(CONF_TURN_OFF_ACTION), - timeout=config.get(CONF_TIMEOUT), websocket=websocket) + timeout=config.get(CONF_TIMEOUT), websocket=websocket, + unique_id=unique_id) hass.data[DATA_KODI][ip_addr] = entity async_add_devices([entity], update_before_add=True) @@ -260,12 +273,14 @@ class KodiDevice(MediaPlayerDevice): def __init__(self, hass, name, host, port, tcp_port, encryption=False, username=None, password=None, turn_on_action=None, turn_off_action=None, - timeout=DEFAULT_TIMEOUT, websocket=True): + timeout=DEFAULT_TIMEOUT, websocket=True, + unique_id=None): """Initialize the Kodi device.""" import jsonrpc_async import jsonrpc_websocket self.hass = hass self._name = name + self._unique_id = unique_id kwargs = { 'timeout': timeout, @@ -384,6 +399,11 @@ class KodiDevice(MediaPlayerDevice): _LOGGER.debug("Unable to fetch kodi data", exc_info=True) return None + @property + def unique_id(self): + """Return the unique id of the device.""" + return self._unique_id + @property def state(self): """Return the state of the device.""" diff --git a/homeassistant/components/media_player/mediaroom.py b/homeassistant/components/media_player/mediaroom.py index f5b7567aa34..32f1bb6e0ae 100644 --- a/homeassistant/components/media_player/mediaroom.py +++ b/homeassistant/components/media_player/mediaroom.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pymediaroom==0.6.3'] +REQUIREMENTS = ['pymediaroom==0.6.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/pjlink.py b/homeassistant/components/media_player/pjlink.py new file mode 100644 index 00000000000..5d3122256ea --- /dev/null +++ b/homeassistant/components/media_player/pjlink.py @@ -0,0 +1,157 @@ +""" +Support for controlling projector via the PJLink protocol. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.pjlink/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, PLATFORM_SCHEMA, MediaPlayerDevice) +from homeassistant.const import ( + STATE_OFF, STATE_ON, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pypjlink2==1.2.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ENCODING = 'encoding' + +DEFAULT_PORT = 4352 +DEFAULT_ENCODING = 'utf-8' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, +}) + +SUPPORT_PJLINK = SUPPORT_VOLUME_MUTE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the PJLink platform.""" + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + encoding = config.get(CONF_ENCODING) + password = config.get(CONF_PASSWORD) + + if 'pjlink' not in hass.data: + hass.data['pjlink'] = {} + hass_data = hass.data['pjlink'] + + device_label = "{}:{}".format(host, port) + if device_label in hass_data: + return + + device = PjLinkDevice(host, port, name, encoding, password) + hass_data[device_label] = device + add_devices([device], True) + + +def format_input_source(input_source_name, input_source_number): + """Format input source for display in UI.""" + return "{} {}".format(input_source_name, input_source_number) + + +class PjLinkDevice(MediaPlayerDevice): + """Representation of a PJLink device.""" + + def __init__(self, host, port, name, encoding, password): + """Iinitialize the PJLink device.""" + self._host = host + self._port = port + self._name = name + self._password = password + self._encoding = encoding + self._muted = False + self._pwstate = STATE_OFF + self._current_source = None + with self.projector() as projector: + if not self._name: + self._name = projector.get_name() + inputs = projector.get_inputs() + self._source_name_mapping = \ + {format_input_source(*x): x for x in inputs} + self._source_list = sorted(self._source_name_mapping.keys()) + + def projector(self): + """Create PJLink Projector instance.""" + from pypjlink import Projector + projector = Projector.from_address(self._host, self._port, + self._encoding) + projector.authenticate(self._password) + return projector + + def update(self): + """Get the latest state from the device.""" + with self.projector() as projector: + pwstate = projector.get_power() + if pwstate == 'off': + self._pwstate = STATE_OFF + else: + self._pwstate = STATE_ON + self._muted = projector.get_mute()[1] + self._current_source = \ + format_input_source(*projector.get_input()) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._pwstate + + @property + def is_volume_muted(self): + """Return boolean indicating mute status.""" + return self._muted + + @property + def source(self): + """Return current input source.""" + return self._current_source + + @property + def source_list(self): + """Return all available input sources.""" + return self._source_list + + @property + def supported_features(self): + """Return projector supported features.""" + return SUPPORT_PJLINK + + def turn_off(self): + """Turn projector off.""" + with self.projector() as projector: + projector.set_power('off') + + def turn_on(self): + """Turn projector on.""" + with self.projector() as projector: + projector.set_power('on') + + def mute_volume(self, mute): + """Mute (true) of unmute (false) media player.""" + with self.projector() as projector: + from pypjlink import MUTE_AUDIO + projector.set_mute(MUTE_AUDIO, mute) + + def select_source(self, source): + """Set the input source.""" + source = self._source_name_mapping[source] + with self.projector() as projector: + projector.set_input(*source) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3928eb945aa..19bacbc8d4c 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -32,7 +32,8 @@ from homeassistant.util.async_ import ( from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_VALUE_TEMPLATE, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_PAYLOAD) -from homeassistant.components.mqtt.server import HBMQTT_CONFIG_SCHEMA + +from .server import HBMQTT_CONFIG_SCHEMA REQUIREMENTS = ['paho-mqtt==1.3.1'] @@ -306,7 +307,8 @@ async def _async_setup_server(hass: HomeAssistantType, return None success, broker_config = \ - await server.async_start(hass, conf.get(CONF_EMBEDDED)) + await server.async_start( + hass, conf.get(CONF_PASSWORD), conf.get(CONF_EMBEDDED)) if not success: return None @@ -349,6 +351,16 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: if CONF_EMBEDDED not in conf and CONF_BROKER in conf: broker_config = None else: + if (conf.get(CONF_PASSWORD) is None and + config.get('http') is not None and + config['http'].get('api_password') is not None): + _LOGGER.error( + "Starting from release 0.76, the embedded MQTT broker does not" + " use api_password as default password anymore. Please set" + " password configuration. See https://home-assistant.io/docs/" + "mqtt/broker#embedded-broker for details") + return False + broker_config = await _async_setup_server(hass, config) if CONF_BROKER in conf: diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py index 8a012928792..5fc365342ae 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -27,27 +27,29 @@ HBMQTT_CONFIG_SCHEMA = vol.Any(None, vol.Schema({ }) }, extra=vol.ALLOW_EXTRA)) +_LOGGER = logging.getLogger(__name__) + @asyncio.coroutine -def async_start(hass, server_config): +def async_start(hass, password, server_config): """Initialize MQTT Server. This method is a coroutine. """ from hbmqtt.broker import Broker, BrokerException + passwd = tempfile.NamedTemporaryFile() try: - passwd = tempfile.NamedTemporaryFile() - if server_config is None: - server_config, client_config = generate_config(hass, passwd) + server_config, client_config = generate_config( + hass, passwd, password) else: client_config = None broker = Broker(server_config, hass.loop) yield from broker.start() except BrokerException: - logging.getLogger(__name__).exception("Error initializing MQTT server") + _LOGGER.exception("Error initializing MQTT server") return False, None finally: passwd.close() @@ -63,9 +65,10 @@ def async_start(hass, server_config): return True, client_config -def generate_config(hass, passwd): +def generate_config(hass, passwd, password): """Generate a configuration based on current Home Assistant instance.""" - from homeassistant.components.mqtt import PROTOCOL_311 + from . import PROTOCOL_311 + config = { 'listeners': { 'default': { @@ -79,29 +82,26 @@ def generate_config(hass, passwd): }, }, 'auth': { - 'allow-anonymous': hass.config.api.api_password is None + 'allow-anonymous': password is None }, 'plugins': ['auth_anonymous'], } - if hass.config.api.api_password: + if password: username = 'homeassistant' - password = hass.config.api.api_password # Encrypt with what hbmqtt uses to verify from passlib.apps import custom_app_context passwd.write( 'homeassistant:{}\n'.format( - custom_app_context.encrypt( - hass.config.api.api_password)).encode('utf-8')) + custom_app_context.encrypt(password)).encode('utf-8')) passwd.flush() config['auth']['password-file'] = passwd.name config['plugins'].append('auth_file') else: username = None - password = None client_config = ('localhost', 1883, username, password, None, PROTOCOL_311) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 980efcf5805..e498539f2f9 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.16.0'] +REQUIREMENTS = ['pymysensors==0.17.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 8c80604d188..88725e67940 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -186,12 +186,16 @@ def _discover_mysensors_platform(hass, platform, new_devices): async def _gw_start(hass, gateway): """Start the gateway.""" + # Don't use hass.async_create_task to avoid holding up setup indefinitely. + connect_task = hass.loop.create_task(gateway.start()) + @callback def gw_stop(event): """Trigger to stop the gateway.""" hass.async_add_job(gateway.stop()) + if not connect_task.done(): + connect_task.cancel() - await gateway.start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop) if gateway.device == 'mqtt': # Gatways connected via mqtt doesn't send gateway ready message. diff --git a/homeassistant/components/nest/.translations/de.json b/homeassistant/components/nest/.translations/de.json index 32c72ef7d96..86b50ab3c10 100644 --- a/homeassistant/components/nest/.translations/de.json +++ b/homeassistant/components/nest/.translations/de.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_setup": "Sie k\u00f6nnen nur ein einziges Nest-Konto konfigurieren.", + "authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL", + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL", "no_flows": "Sie m\u00fcssen Nest konfigurieren, bevor Sie sich authentifizieren k\u00f6nnen. [Bitte lesen Sie die Anweisungen] (https://www.home-assistant.io/components/nest/)." }, "error": { diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 1adb113bb81..d25b94bbc17 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -4,19 +4,21 @@ Support for Nest devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/nest/ """ -from concurrent.futures import ThreadPoolExecutor import logging import socket from datetime import datetime, timedelta +import threading import voluptuous as vol +from homeassistant import config_entries from homeassistant.const import ( CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, CONF_MONITORED_CONDITIONS, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send, \ +from homeassistant.helpers.dispatcher import dispatcher_send, \ async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -70,24 +72,25 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -async def async_nest_update_event_broker(hass, nest): +def nest_update_event_broker(hass, nest): """ Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data. - nest.update_event.wait will block the thread in most of time, - so specific an executor to save default thread pool. + Runs in its own thread. """ _LOGGER.debug("listening nest.update_event") - with ThreadPoolExecutor(max_workers=1) as executor: - while True: - await hass.loop.run_in_executor(executor, nest.update_event.wait) - if hass.is_running: - nest.update_event.clear() - _LOGGER.debug("dispatching nest data update") - async_dispatcher_send(hass, SIGNAL_NEST_UPDATE) - else: - _LOGGER.debug("stop listening nest.update_event") - return + + while hass.is_running: + nest.update_event.wait() + + if not hass.is_running: + break + + nest.update_event.clear() + _LOGGER.debug("dispatching nest data update") + dispatcher_send(hass, SIGNAL_NEST_UPDATE) + + _LOGGER.debug("stop listening nest.update_event") async def async_setup(hass, config): @@ -103,7 +106,8 @@ async def async_setup(hass, config): access_token_cache_file = hass.config.path(filename) hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, source='import', data={ + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ 'nest_conf_path': access_token_cache_file, } )) @@ -165,16 +169,21 @@ async def async_setup_entry(hass, entry): hass.services.async_register( DOMAIN, 'set_mode', set_mode, schema=AWAY_SCHEMA) + @callback def start_up(event): """Start Nest update event listener.""" - hass.async_add_job(async_nest_update_event_broker, hass, nest) + threading.Thread( + name='Nest update listener', + target=nest_update_event_broker, + args=(hass, nest) + ).start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_up) + @callback def shut_down(event): """Stop Nest update event listener.""" - if nest: - nest.update_event.set() + nest.update_event.set() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index f97e0dc8ff5..c9987693b1a 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -58,6 +58,10 @@ class NestFlowHandler(data_entry_flow.FlowHandler): """Initialize the Nest config flow.""" self.flow_impl = None + 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 flow start.""" flows = self.hass.data.get(DATA_FLOW_IMPL, {}) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index e280aa67e40..1ed50472004 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -26,7 +26,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string -REQUIREMENTS = ['pywebpush==1.6.0', 'PyJWT==1.6.0'] +REQUIREMENTS = ['pywebpush==1.6.0'] DEPENDENCIES = ['frontend'] diff --git a/homeassistant/components/notify/mastodon.py b/homeassistant/components/notify/mastodon.py index e29289722e8..095e3f98ff9 100644 --- a/homeassistant/components/notify/mastodon.py +++ b/homeassistant/components/notify/mastodon.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_ACCESS_TOKEN import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['Mastodon.py==1.3.0'] +REQUIREMENTS = ['Mastodon.py==1.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/telstra.py b/homeassistant/components/notify/telstra.py deleted file mode 100644 index 82ac914a647..00000000000 --- a/homeassistant/components/notify/telstra.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Telstra API platform for notify component. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.telstra/ -""" -import logging - -from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION -import requests -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_TITLE, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONTENT_TYPE_JSON -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_CONSUMER_KEY = 'consumer_key' -CONF_CONSUMER_SECRET = 'consumer_secret' -CONF_PHONE_NUMBER = 'phone_number' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_CONSUMER_KEY): cv.string, - vol.Required(CONF_CONSUMER_SECRET): cv.string, - vol.Required(CONF_PHONE_NUMBER): cv.string, -}) - - -def get_service(hass, config, discovery_info=None): - """Get the Telstra SMS API notification service.""" - consumer_key = config.get(CONF_CONSUMER_KEY) - consumer_secret = config.get(CONF_CONSUMER_SECRET) - phone_number = config.get(CONF_PHONE_NUMBER) - - if _authenticate(consumer_key, consumer_secret) is False: - _LOGGER.exception("Error obtaining authorization from Telstra API") - return None - - return TelstraNotificationService( - consumer_key, consumer_secret, phone_number) - - -class TelstraNotificationService(BaseNotificationService): - """Implementation of a notification service for the Telstra SMS API.""" - - def __init__(self, consumer_key, consumer_secret, phone_number): - """Initialize the service.""" - self._consumer_key = consumer_key - self._consumer_secret = consumer_secret - self._phone_number = phone_number - - def send_message(self, message="", **kwargs): - """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) - - # Retrieve authorization first - token_response = _authenticate( - self._consumer_key, self._consumer_secret) - if token_response is False: - _LOGGER.exception("Error obtaining authorization from Telstra API") - return - - # Send the SMS - if title: - text = '{} {}'.format(title, message) - else: - text = message - - message_data = { - 'to': self._phone_number, - 'body': text, - } - message_resource = 'https://api.telstra.com/v1/sms/messages' - message_headers = { - CONTENT_TYPE: CONTENT_TYPE_JSON, - AUTHORIZATION: 'Bearer {}'.format(token_response['access_token']), - } - message_response = requests.post( - message_resource, headers=message_headers, json=message_data, - timeout=10) - - if message_response.status_code != 202: - _LOGGER.exception("Failed to send SMS. Status code: %d", - message_response.status_code) - - -def _authenticate(consumer_key, consumer_secret): - """Authenticate with the Telstra API.""" - token_data = { - 'client_id': consumer_key, - 'client_secret': consumer_secret, - 'grant_type': 'client_credentials', - 'scope': 'SMS' - } - token_resource = 'https://api.telstra.com/v1/oauth/token' - token_response = requests.get( - token_resource, params=token_data, timeout=10).json() - - if 'error' in token_response: - return False - - return token_response diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 6dea5919f09..52d18b9a870 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -2,9 +2,10 @@ from homeassistant.core import callback from homeassistant.loader import bind_hass -from .const import STEPS, STEP_USER, DOMAIN +from .const import DOMAIN, STEP_USER, STEPS DEPENDENCIES = ['http'] + STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -21,7 +22,7 @@ def async_is_onboarded(hass): async def async_setup(hass, config): - """Set up the onboard component.""" + """Set up the onboarding component.""" store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) data = await store.async_load() diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 17d83003c48..497fa827f08 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,21 +3,21 @@ import asyncio import voluptuous as vol -from homeassistant.core import callback -from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.core import callback -from .const import DOMAIN, STEPS, STEP_USER +from .const import DOMAIN, STEP_USER, STEPS async def async_setup(hass, data, store): - """Setup onboarding.""" + """Set up the onboarding view.""" hass.http.register_view(OnboardingView(data, store)) hass.http.register_view(UserOnboardingView(data, store)) class OnboardingView(HomeAssistantView): - """Returns the onboarding status.""" + """Return the onboarding status.""" requires_auth = False url = '/api/onboarding' diff --git a/homeassistant/components/openuv.py b/homeassistant/components/openuv.py new file mode 100644 index 00000000000..dd038611ae9 --- /dev/null +++ b/homeassistant/components/openuv.py @@ -0,0 +1,182 @@ +""" +Support for data from openuv.io. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/openuv/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_BINARY_SENSORS, CONF_ELEVATION, + CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + CONF_SCAN_INTERVAL, CONF_SENSORS) +from homeassistant.helpers import ( + aiohttp_client, config_validation as cv, discovery) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval + +REQUIREMENTS = ['pyopenuv==1.0.1'] +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'openuv' + +DATA_PROTECTION_WINDOW = 'protection_window' +DATA_UV = 'uv' + +DEFAULT_ATTRIBUTION = 'Data provided by OpenUV' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +NOTIFICATION_ID = 'openuv_notification' +NOTIFICATION_TITLE = 'OpenUV Component Setup' + +TOPIC_UPDATE = '{0}_data_update'.format(DOMAIN) + +TYPE_CURRENT_OZONE_LEVEL = 'current_ozone_level' +TYPE_CURRENT_UV_INDEX = 'current_uv_index' +TYPE_MAX_UV_INDEX = 'max_uv_index' +TYPE_PROTECTION_WINDOW = 'uv_protection_window' +TYPE_SAFE_EXPOSURE_TIME_1 = 'safe_exposure_time_type_1' +TYPE_SAFE_EXPOSURE_TIME_2 = 'safe_exposure_time_type_2' +TYPE_SAFE_EXPOSURE_TIME_3 = 'safe_exposure_time_type_3' +TYPE_SAFE_EXPOSURE_TIME_4 = 'safe_exposure_time_type_4' +TYPE_SAFE_EXPOSURE_TIME_5 = 'safe_exposure_time_type_5' +TYPE_SAFE_EXPOSURE_TIME_6 = 'safe_exposure_time_type_6' + +BINARY_SENSORS = { + TYPE_PROTECTION_WINDOW: ('Protection Window', 'mdi:sunglasses') +} + +BINARY_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]) +}) + +SENSORS = { + TYPE_CURRENT_OZONE_LEVEL: ( + 'Current Ozone Level', 'mdi:vector-triangle', 'du'), + TYPE_CURRENT_UV_INDEX: ('Current UV Index', 'mdi:weather-sunny', 'index'), + TYPE_MAX_UV_INDEX: ('Max UV Index', 'mdi:weather-sunny', 'index'), + TYPE_SAFE_EXPOSURE_TIME_1: ( + 'Skin Type 1 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_2: ( + 'Skin Type 2 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_3: ( + 'Skin Type 3 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_4: ( + 'Skin Type 4 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_5: ( + 'Skin Type 5 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_6: ( + 'Skin Type 6 Safe Exposure Time', 'mdi:timer', 'minutes'), +} + +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_API_KEY): cv.string, + vol.Optional(CONF_ELEVATION): float, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + 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 OpenUV component.""" + from pyopenuv import Client + from pyopenuv.errors import OpenUvError + + conf = config[DOMAIN] + api_key = conf[CONF_API_KEY] + elevation = conf.get(CONF_ELEVATION, hass.config.elevation) + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + + try: + websession = aiohttp_client.async_get_clientsession(hass) + openuv = OpenUV( + Client( + api_key, latitude, longitude, websession, altitude=elevation), + conf[CONF_BINARY_SENSORS][CONF_MONITORED_CONDITIONS] + + conf[CONF_SENSORS][CONF_MONITORED_CONDITIONS]) + await openuv.async_update() + hass.data[DOMAIN] = openuv + except OpenUvError 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 + + for component, schema in [ + ('binary_sensor', conf[CONF_BINARY_SENSORS]), + ('sensor', conf[CONF_SENSORS]), + ]: + hass.async_create_task( + discovery.async_load_platform( + hass, component, DOMAIN, schema, config)) + + async def refresh_sensors(event_time): + """Refresh OpenUV data.""" + _LOGGER.debug('Refreshing OpenUV data') + await openuv.async_update() + async_dispatcher_send(hass, TOPIC_UPDATE) + + async_track_time_interval(hass, refresh_sensors, conf[CONF_SCAN_INTERVAL]) + + return True + + +class OpenUV: + """Define a generic OpenUV object.""" + + def __init__(self, client, monitored_conditions): + """Initialize.""" + self._monitored_conditions = monitored_conditions + self.client = client + self.data = {} + + async def async_update(self): + """Update sensor/binary sensor data.""" + if TYPE_PROTECTION_WINDOW in self._monitored_conditions: + data = await self.client.uv_protection_window() + self.data[DATA_PROTECTION_WINDOW] = data + + if any(c in self._monitored_conditions for c in SENSORS): + data = await self.client.uv_index() + self.data[DATA_UV] = data + + +class OpenUvEntity(Entity): + """Define a generic OpenUV entity.""" + + def __init__(self, openuv): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._name = None + self.openuv = openuv + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def name(self): + """Return the name of the entity.""" + return self._name diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index cce3550d35c..2850a5f96cd 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -6,10 +6,11 @@ https://home-assistant.io/components/persistent_notification/ """ import asyncio import logging +from typing import Awaitable import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import callback, HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.loader import bind_hass from homeassistant.helpers import config_validation as cv @@ -58,7 +59,8 @@ def dismiss(hass, notification_id): @callback @bind_hass -def async_create(hass, message, title=None, notification_id=None): +def async_create(hass: HomeAssistant, message: str, title: str = None, + notification_id: str = None) -> None: """Generate a notification.""" data = { key: value for key, value in [ @@ -68,7 +70,8 @@ def async_create(hass, message, title=None, notification_id=None): ] if value is not None } - hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_CREATE, data)) + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_CREATE, data)) @callback @@ -81,7 +84,7 @@ def async_dismiss(hass, notification_id): @asyncio.coroutine -def async_setup(hass, config): +def async_setup(hass: HomeAssistant, config: dict) -> Awaitable[bool]: """Set up the persistent notification component.""" @callback def create_service(call): diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index af70c9d998c..939985ebfb1 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -114,6 +114,27 @@ def _drop_index(engine, table_name, index_name): "critical operation.", index_name, table_name) +def _add_columns(engine, table_name, columns_def): + """Add columns to a table.""" + from sqlalchemy import text + from sqlalchemy.exc import SQLAlchemyError + + columns_def = ['ADD COLUMN {}'.format(col_def) for col_def in columns_def] + + try: + engine.execute(text("ALTER TABLE {table} {columns_def}".format( + table=table_name, + columns_def=', '.join(columns_def)))) + return + except SQLAlchemyError: + pass + + for column_def in columns_def: + engine.execute(text("ALTER TABLE {table} {column_def}".format( + table=table_name, + column_def=column_def))) + + def _apply_update(engine, new_version, old_version): """Perform operations to bring schema up to date.""" if new_version == 1: @@ -146,6 +167,19 @@ def _apply_update(engine, new_version, old_version): elif new_version == 5: # Create supporting index for States.event_id foreign key _create_index(engine, "states", "ix_states_event_id") + elif new_version == 6: + _add_columns(engine, "events", [ + 'context_id CHARACTER(36)', + 'context_user_id CHARACTER(36)', + ]) + _create_index(engine, "events", "ix_events_context_id") + _create_index(engine, "events", "ix_events_context_user_id") + _add_columns(engine, "states", [ + 'context_id CHARACTER(36)', + 'context_user_id CHARACTER(36)', + ]) + _create_index(engine, "states", "ix_states_context_id") + _create_index(engine, "states", "ix_states_context_user_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 e7948446231..b8b777990f7 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -9,14 +9,15 @@ from sqlalchemy import ( from sqlalchemy.ext.declarative import declarative_base import homeassistant.util.dt as dt_util -from homeassistant.core import Event, EventOrigin, State, split_entity_id +from homeassistant.core import ( + Context, Event, EventOrigin, State, split_entity_id) from homeassistant.remote import JSONEncoder # SQLAlchemy Schema # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 _LOGGER = logging.getLogger(__name__) @@ -31,6 +32,8 @@ class Events(Base): # type: ignore origin = Column(String(32)) time_fired = Column(DateTime(timezone=True), index=True) created = Column(DateTime(timezone=True), default=datetime.utcnow) + context_id = Column(String(36), index=True) + context_user_id = Column(String(36), index=True) @staticmethod def from_event(event): @@ -38,16 +41,23 @@ class Events(Base): # type: ignore return Events(event_type=event.event_type, event_data=json.dumps(event.data, cls=JSONEncoder), origin=str(event.origin), - time_fired=event.time_fired) + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id) def to_native(self): """Convert to a natve HA Event.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id + ) try: return Event( self.event_type, json.loads(self.event_data), EventOrigin(self.origin), - _process_timestamp(self.time_fired) + _process_timestamp(self.time_fired), + context=context, ) except ValueError: # When json.loads fails @@ -69,6 +79,8 @@ class States(Base): # type: ignore last_updated = Column(DateTime(timezone=True), default=datetime.utcnow, index=True) created = Column(DateTime(timezone=True), default=datetime.utcnow) + context_id = Column(String(36), index=True) + context_user_id = Column(String(36), index=True) __table_args__ = ( # Used for fetching the state of entities at a specific time @@ -82,7 +94,11 @@ class States(Base): # type: ignore entity_id = event.data['entity_id'] state = event.data.get('new_state') - dbstate = States(entity_id=entity_id) + dbstate = States( + entity_id=entity_id, + context_id=event.context.id, + context_user_id=event.context.user_id, + ) # State got deleted if state is None: @@ -103,12 +119,17 @@ class States(Base): # type: ignore def to_native(self): """Convert to an HA state object.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id + ) try: return State( self.entity_id, self.state, json.loads(self.attributes), _process_timestamp(self.last_changed), - _process_timestamp(self.last_updated) + _process_timestamp(self.last_updated), + context=context, ) except ValueError: # When json.loads fails diff --git a/homeassistant/components/sensor/.translations/moon.ar.json b/homeassistant/components/sensor/.translations/moon.ar.json new file mode 100644 index 00000000000..94af741f5f4 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ar.json @@ -0,0 +1,6 @@ +{ + "state": { + "first_quarter": "\u0627\u0644\u0631\u0628\u0639 \u0627\u0644\u0623\u0648\u0644", + "full_moon": "\u0627\u0644\u0642\u0645\u0631 \u0627\u0644\u0643\u0627\u0645\u0644" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.ca.json b/homeassistant/components/sensor/.translations/moon.ca.json new file mode 100644 index 00000000000..56eaf8d3b4c --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ca.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Quart creixent", + "full_moon": "Lluna plena", + "last_quarter": "Quart minvant", + "new_moon": "Lluna nova", + "waning_crescent": "Lluna vella minvant", + "waning_gibbous": "Gibosa minvant", + "waxing_crescent": "Lluna nova visible", + "waxing_gibbous": "Gibosa creixent" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.de.json b/homeassistant/components/sensor/.translations/moon.de.json new file mode 100644 index 00000000000..aebca53ec4d --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.de.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Erstes Viertel", + "full_moon": "Vollmond", + "last_quarter": "Letztes Viertel", + "new_moon": "Neumond", + "waning_crescent": "Abnehmende Sichel", + "waning_gibbous": "Drittes Viertel", + "waxing_crescent": " Zunehmende Sichel", + "waxing_gibbous": "Zweites Viertel" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.en.json b/homeassistant/components/sensor/.translations/moon.en.json new file mode 100644 index 00000000000..587b9496114 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.en.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "First quarter", + "full_moon": "Full moon", + "last_quarter": "Last quarter", + "new_moon": "New moon", + "waning_crescent": "Waning crescent", + "waning_gibbous": "Waning gibbous", + "waxing_crescent": "Waxing crescent", + "waxing_gibbous": "Waxing gibbous" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.es-419.json b/homeassistant/components/sensor/.translations/moon.es-419.json new file mode 100644 index 00000000000..71cfab736cb --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.es-419.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Cuarto creciente", + "full_moon": "Luna llena", + "last_quarter": "Cuarto menguante", + "new_moon": "Luna nueva", + "waning_crescent": "Luna menguante", + "waning_gibbous": "Luna menguante gibosa", + "waxing_crescent": "Luna creciente", + "waxing_gibbous": "Luna creciente gibosa" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.fr.json b/homeassistant/components/sensor/.translations/moon.fr.json new file mode 100644 index 00000000000..fac2b654a46 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.fr.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Premier quartier", + "full_moon": "Pleine lune", + "last_quarter": "Dernier quartier", + "new_moon": "Nouvelle lune", + "waning_crescent": "Dernier croissant", + "waning_gibbous": "Gibbeuse d\u00e9croissante", + "waxing_crescent": "Premier croissant", + "waxing_gibbous": "Gibbeuse croissante" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.ko.json b/homeassistant/components/sensor/.translations/moon.ko.json new file mode 100644 index 00000000000..7e62250b892 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ko.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\ubc18\ub2ec(\ucc28\uc624\ub974\ub294)", + "full_moon": "\ubcf4\ub984\ub2ec", + "last_quarter": "\ubc18\ub2ec(\uc904\uc5b4\ub4dc\ub294)", + "new_moon": "\uc0ad\uc6d4", + "waning_crescent": "\uadf8\ubbd0\ub2ec", + "waning_gibbous": "\ud558\ud604\ub2ec", + "waxing_crescent": "\ucd08\uc2b9\ub2ec", + "waxing_gibbous": "\uc0c1\ud604\ub2ec" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.nl.json b/homeassistant/components/sensor/.translations/moon.nl.json new file mode 100644 index 00000000000..5e78d429b9f --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.nl.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Eerste kwartier", + "full_moon": "Volle maan", + "last_quarter": "Laatste kwartier", + "new_moon": "Nieuwe maan", + "waning_crescent": "Krimpende, sikkelvormige maan", + "waning_gibbous": "Krimpende, vooruitspringende maan", + "waxing_crescent": "Wassende, sikkelvormige maan", + "waxing_gibbous": "Wassende, vooruitspringende maan" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.no.json b/homeassistant/components/sensor/.translations/moon.no.json new file mode 100644 index 00000000000..104412c90ba --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.no.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "F\u00f8rste kvartdel", + "full_moon": "Fullm\u00e5ne", + "last_quarter": "Siste kvartdel", + "new_moon": "Nym\u00e5ne", + "waning_crescent": "Minkende halvm\u00e5ne", + "waning_gibbous": "Minkende trekvartm\u00e5ne", + "waxing_crescent": "Voksende halvm\u00e5ne", + "waxing_gibbous": "Voksende trekvartm\u00e5ne" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.ru.json b/homeassistant/components/sensor/.translations/moon.ru.json new file mode 100644 index 00000000000..6db932a1aed --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ru.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\u041f\u0435\u0440\u0432\u0430\u044f \u0447\u0435\u0442\u0432\u0435\u0440\u0442\u044c", + "full_moon": "\u041f\u043e\u043b\u043d\u043e\u043b\u0443\u043d\u0438\u0435", + "last_quarter": "\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u044f\u044f \u0447\u0435\u0442\u0432\u0435\u0440\u0442\u044c", + "new_moon": "\u041d\u043e\u0432\u043e\u043b\u0443\u043d\u0438\u0435", + "waning_crescent": "\u0421\u0442\u0430\u0440\u0430\u044f \u043b\u0443\u043d\u0430", + "waning_gibbous": "\u0423\u0431\u044b\u0432\u0430\u044e\u0449\u0430\u044f \u043b\u0443\u043d\u0430", + "waxing_crescent": "\u041c\u043e\u043b\u043e\u0434\u0430\u044f \u043b\u0443\u043d\u0430", + "waxing_gibbous": "\u041f\u0440\u0438\u0431\u044b\u0432\u0430\u044e\u0449\u0430\u044f \u043b\u0443\u043d\u0430" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.sl.json b/homeassistant/components/sensor/.translations/moon.sl.json new file mode 100644 index 00000000000..41e873e4def --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.sl.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Prvi krajec", + "full_moon": "Polna luna", + "last_quarter": "Zadnji krajec", + "new_moon": "Mlaj", + "waning_crescent": "Zadnji izbo\u010dec", + "waning_gibbous": "Zadnji srpec", + "waxing_crescent": " Prvi izbo\u010dec", + "waxing_gibbous": "Prvi srpec" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.zh-Hans.json b/homeassistant/components/sensor/.translations/moon.zh-Hans.json new file mode 100644 index 00000000000..22ab0d49f62 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.zh-Hans.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\u4e0a\u5f26\u6708", + "full_moon": "\u6ee1\u6708", + "last_quarter": "\u4e0b\u5f26\u6708", + "new_moon": "\u65b0\u6708", + "waning_crescent": "\u6b8b\u6708", + "waning_gibbous": "\u4e8f\u51f8\u6708", + "waxing_crescent": "\u5ce8\u7709\u6708", + "waxing_gibbous": "\u76c8\u51f8\u6708" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.zh-Hant.json b/homeassistant/components/sensor/.translations/moon.zh-Hant.json new file mode 100644 index 00000000000..9cf4aad011e --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.zh-Hant.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\u4e0a\u5f26\u6708", + "full_moon": "\u6eff\u6708", + "last_quarter": "\u4e0b\u5f26\u6708", + "new_moon": "\u65b0\u6708", + "waning_crescent": "\u6b98\u6708", + "waning_gibbous": "\u8667\u51f8\u6708", + "waxing_crescent": "\u86fe\u7709\u6708", + "waxing_gibbous": "\u76c8\u51f8\u6708" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 77d8ba9322f..a7e6f6d2622 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -22,7 +22,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_CLOSE = 'close' ATTR_HIGH = 'high' ATTR_LOW = 'low' -ATTR_VOLUME = 'volume' CONF_ATTRIBUTION = "Stock market information provided by Alpha Vantage" CONF_FOREIGN_EXCHANGE = 'foreign_exchange' @@ -148,7 +147,6 @@ class AlphaVantageSensor(Entity): ATTR_CLOSE: self.values['4. close'], ATTR_HIGH: self.values['2. high'], ATTR_LOW: self.values['3. low'], - ATTR_VOLUME: self.values['5. volume'], } @property diff --git a/homeassistant/components/sensor/enphase_envoy.py b/homeassistant/components/sensor/enphase_envoy.py new file mode 100644 index 00000000000..3c132fcf7df --- /dev/null +++ b/homeassistant/components/sensor/enphase_envoy.py @@ -0,0 +1,107 @@ +""" +Support for Enphase Envoy solar energy monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.enphase_envoy/ +""" +import logging + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_IP_ADDRESS, CONF_MONITORED_CONDITIONS) + + +REQUIREMENTS = ['envoy_reader==0.1'] +_LOGGER = logging.getLogger(__name__) + +SENSORS = { + "production": ("Envoy Current Energy Production", 'W'), + "daily_production": ("Envoy Today's Energy Production", "Wh"), + "7_days_production": ("Envoy Last Seven Days Energy Production", "Wh"), + "lifetime_production": ("Envoy Lifetime Energy Production", "Wh"), + "consumption": ("Envoy Current Energy Consumption", "W"), + "daily_consumption": ("Envoy Today's Energy Consumption", "Wh"), + "7_days_consumption": ("Envoy Last Seven Days Energy Consumption", "Wh"), + "lifetime_consumption": ("Envoy Lifetime Energy Consumption", "Wh") + } + + +ICON = 'mdi:flash' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(list(SENSORS))])}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Enphase Envoy sensor.""" + ip_address = config[CONF_IP_ADDRESS] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + + # Iterate through the list of sensors + for condition in monitored_conditions: + add_devices([Envoy(ip_address, condition, SENSORS[condition][0], + SENSORS[condition][1])], True) + + +class Envoy(Entity): + """Implementation of the Enphase Envoy sensors.""" + + def __init__(self, ip_address, sensor_type, name, unit): + """Initialize the sensor.""" + self._ip_address = ip_address + self._name = name + self._unit_of_measurement = unit + self._type = sensor_type + self._state = 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 + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the energy production data from the Enphase Envoy.""" + import envoy_reader + + if self._type == "production": + self._state = int(envoy_reader.production(self._ip_address)) + elif self._type == "daily_production": + self._state = int(envoy_reader.daily_production(self._ip_address)) + elif self._type == "7_days_production": + self._state = int(envoy_reader.seven_days_production( + self._ip_address)) + elif self._type == "lifetime_production": + self._state = int(envoy_reader.lifetime_production( + self._ip_address)) + + elif self._type == "consumption": + self._state = int(envoy_reader.consumption(self._ip_address)) + elif self._type == "daily_consumption": + self._state = int(envoy_reader.daily_consumption( + self._ip_address)) + elif self._type == "7_days_consumption": + self._state = int(envoy_reader.seven_days_consumption( + self._ip_address)) + elif self._type == "lifetime_consumption": + self._state = int(envoy_reader.lifetime_consumption( + self._ip_address)) diff --git a/homeassistant/components/sensor/irish_rail_transport.py b/homeassistant/components/sensor/irish_rail_transport.py index 5febebeec87..38fd910260a 100644 --- a/homeassistant/components/sensor/irish_rail_transport.py +++ b/homeassistant/components/sensor/irish_rail_transport.py @@ -164,7 +164,7 @@ class IrishRailTransportData: ATTR_TRAIN_TYPE: train.get('type')} self.info.append(train_data) - if not self.info or not self.info: + if not self.info: self.info = self._empty_train_data() def _empty_train_data(self): diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index 6ee3f7d16d0..45eddee9f7e 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylast==2.3.0'] +REQUIREMENTS = ['pylast==2.4.0'] ATTR_LAST_PLAYED = 'last_played' ATTR_PLAY_COUNT = 'play_count' diff --git a/homeassistant/components/sensor/min_max.py b/homeassistant/components/sensor/min_max.py index 912bf7b7500..f3a30724732 100644 --- a/homeassistant/components/sensor/min_max.py +++ b/homeassistant/components/sensor/min_max.py @@ -124,7 +124,6 @@ class MinMaxSensor(Entity): self.states = {} @callback - # pylint: disable=invalid-name def async_min_max_sensor_state_listener(entity, old_state, new_state): """Handle the sensor state changes.""" if new_state.state is None or new_state.state in STATE_UNKNOWN: diff --git a/homeassistant/components/sensor/opensky.py b/homeassistant/components/sensor/opensky.py index af0491cc26c..9178b46c488 100644 --- a/homeassistant/components/sensor/opensky.py +++ b/homeassistant/components/sensor/opensky.py @@ -13,22 +13,27 @@ 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_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, - LENGTH_KILOMETERS, LENGTH_METERS) + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, + CONF_RADIUS, ATTR_ATTRIBUTION, ATTR_LATITUDE, + ATTR_LONGITUDE, LENGTH_KILOMETERS, LENGTH_METERS) from homeassistant.helpers.entity import Entity from homeassistant.util import distance as util_distance from homeassistant.util import location as util_location _LOGGER = logging.getLogger(__name__) +CONF_ALTITUDE = 'altitude' + ATTR_CALLSIGN = 'callsign' +ATTR_ALTITUDE = 'altitude' ATTR_ON_GROUND = 'on_ground' ATTR_SENSOR = 'sensor' ATTR_STATES = 'states' DOMAIN = 'opensky' +DEFAULT_ALTITUDE = 0 + EVENT_OPENSKY_ENTRY = '{}_entry'.format(DOMAIN) EVENT_OPENSKY_EXIT = '{}_exit'.format(DOMAIN) SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds @@ -38,7 +43,7 @@ OPENSKY_ATTRIBUTION = "Information provided by the OpenSky Network "\ OPENSKY_API_URL = 'https://opensky-network.org/api/states/all' OPENSKY_API_FIELDS = [ 'icao24', ATTR_CALLSIGN, 'origin_country', 'time_position', - 'time_velocity', ATTR_LONGITUDE, ATTR_LATITUDE, 'altitude', + 'time_velocity', ATTR_LONGITUDE, ATTR_LATITUDE, ATTR_ALTITUDE, ATTR_ON_GROUND, 'velocity', 'heading', 'vertical_rate', 'sensors'] @@ -46,7 +51,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RADIUS): vol.Coerce(float), vol.Optional(CONF_NAME): cv.string, vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude + vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude, + vol.Optional(CONF_ALTITUDE, default=DEFAULT_ALTITUDE): vol.Coerce(float) }) @@ -56,19 +62,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): longitude = config.get(CONF_LONGITUDE, hass.config.longitude) add_devices([OpenSkySensor( hass, config.get(CONF_NAME, DOMAIN), latitude, longitude, - config.get(CONF_RADIUS))], True) + config.get(CONF_RADIUS), config.get(CONF_ALTITUDE))], True) class OpenSkySensor(Entity): """Open Sky Network Sensor.""" - def __init__(self, hass, name, latitude, longitude, radius): + def __init__(self, hass, name, latitude, longitude, radius, altitude): """Initialize the sensor.""" self._session = requests.Session() self._latitude = latitude self._longitude = longitude self._radius = util_distance.convert( radius, LENGTH_KILOMETERS, LENGTH_METERS) + self._altitude = altitude self._state = 0 self._hass = hass self._name = name @@ -84,11 +91,18 @@ class OpenSkySensor(Entity): """Return the state of the sensor.""" return self._state - def _handle_boundary(self, callsigns, event): + def _handle_boundary(self, flights, event, metadata): """Handle flights crossing region boundary.""" - for callsign in callsigns: + for flight in flights: + if flight in metadata: + altitude = metadata[flight].get(ATTR_ALTITUDE) + else: + # Assume Flight has landed if missing. + altitude = 0 + data = { - ATTR_CALLSIGN: callsign, + ATTR_CALLSIGN: flight, + ATTR_ALTITUDE: altitude, ATTR_SENSOR: self._name, } self._hass.bus.fire(event, data) @@ -96,30 +110,37 @@ class OpenSkySensor(Entity): def update(self): """Update device state.""" currently_tracked = set() + flight_metadata = {} states = self._session.get(OPENSKY_API_URL).json().get(ATTR_STATES) for state in states: - data = dict(zip(OPENSKY_API_FIELDS, state)) + flight = dict(zip(OPENSKY_API_FIELDS, state)) + callsign = flight[ATTR_CALLSIGN].strip() + if callsign != '': + flight_metadata[callsign] = flight + else: + continue missing_location = ( - data.get(ATTR_LONGITUDE) is None or - data.get(ATTR_LATITUDE) is None) + flight.get(ATTR_LONGITUDE) is None or + flight.get(ATTR_LATITUDE) is None) if missing_location: continue - if data.get(ATTR_ON_GROUND): + if flight.get(ATTR_ON_GROUND): continue distance = util_location.distance( self._latitude, self._longitude, - data.get(ATTR_LATITUDE), data.get(ATTR_LONGITUDE)) + flight.get(ATTR_LATITUDE), flight.get(ATTR_LONGITUDE)) if distance is None or distance > self._radius: continue - callsign = data[ATTR_CALLSIGN].strip() - if callsign == '': + altitude = flight.get(ATTR_ALTITUDE) + if altitude > self._altitude and self._altitude != 0: continue currently_tracked.add(callsign) if self._previously_tracked is not None: entries = currently_tracked - self._previously_tracked exits = self._previously_tracked - currently_tracked - self._handle_boundary(entries, EVENT_OPENSKY_ENTRY) - self._handle_boundary(exits, EVENT_OPENSKY_EXIT) + self._handle_boundary(entries, EVENT_OPENSKY_ENTRY, + flight_metadata) + self._handle_boundary(exits, EVENT_OPENSKY_EXIT, flight_metadata) self._state = len(currently_tracked) self._previously_tracked = currently_tracked diff --git a/homeassistant/components/sensor/openuv.py b/homeassistant/components/sensor/openuv.py new file mode 100644 index 00000000000..b30c2908c40 --- /dev/null +++ b/homeassistant/components/sensor/openuv.py @@ -0,0 +1,121 @@ +""" +This platform provides sensors for OpenUV data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.openuv/ +""" +import logging + +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.openuv import ( + DATA_UV, DOMAIN, SENSORS, TOPIC_UPDATE, TYPE_CURRENT_OZONE_LEVEL, + TYPE_CURRENT_UV_INDEX, TYPE_MAX_UV_INDEX, TYPE_SAFE_EXPOSURE_TIME_1, + TYPE_SAFE_EXPOSURE_TIME_2, TYPE_SAFE_EXPOSURE_TIME_3, + TYPE_SAFE_EXPOSURE_TIME_4, TYPE_SAFE_EXPOSURE_TIME_5, + TYPE_SAFE_EXPOSURE_TIME_6, OpenUvEntity) +from homeassistant.util.dt import as_local, parse_datetime + +DEPENDENCIES = ['openuv'] +_LOGGER = logging.getLogger(__name__) + +ATTR_MAX_UV_TIME = 'time' + +EXPOSURE_TYPE_MAP = { + TYPE_SAFE_EXPOSURE_TIME_1: 'st1', + TYPE_SAFE_EXPOSURE_TIME_2: 'st2', + TYPE_SAFE_EXPOSURE_TIME_3: 'st3', + TYPE_SAFE_EXPOSURE_TIME_4: 'st4', + TYPE_SAFE_EXPOSURE_TIME_5: 'st5', + TYPE_SAFE_EXPOSURE_TIME_6: 'st6' +} + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the OpenUV binary sensor platform.""" + if discovery_info is None: + return + + openuv = hass.data[DOMAIN] + + sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon, unit = SENSORS[sensor_type] + sensors.append(OpenUvSensor(openuv, sensor_type, name, icon, unit)) + + async_add_devices(sensors, True) + + +class OpenUvSensor(OpenUvEntity): + """Define a binary sensor for OpenUV.""" + + def __init__(self, openuv, sensor_type, name, icon, unit): + """Initialize the sensor.""" + super().__init__(openuv) + + self._icon = icon + self._latitude = openuv.client.latitude + self._longitude = openuv.client.longitude + self._name = name + self._sensor_type = sensor_type + self._state = None + self._unit = unit + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def state(self): + """Return the status of the sensor.""" + return self._state + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}_{2}'.format( + self._latitude, self._longitude, self._sensor_type) + + @property + def unit_of_measurement(self): + """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, TOPIC_UPDATE, self._update_data) + + async def async_update(self): + """Update the state.""" + data = self.openuv.data[DATA_UV]['result'] + if self._sensor_type == TYPE_CURRENT_OZONE_LEVEL: + self._state = data['ozone'] + elif self._sensor_type == TYPE_CURRENT_UV_INDEX: + self._state = data['uv'] + elif self._sensor_type == TYPE_MAX_UV_INDEX: + self._state = data['uv_max'] + self._attrs.update({ + ATTR_MAX_UV_TIME: as_local( + parse_datetime(data['uv_max_time'])) + }) + elif self._sensor_type in (TYPE_SAFE_EXPOSURE_TIME_1, + TYPE_SAFE_EXPOSURE_TIME_2, + TYPE_SAFE_EXPOSURE_TIME_3, + TYPE_SAFE_EXPOSURE_TIME_4, + TYPE_SAFE_EXPOSURE_TIME_5, + TYPE_SAFE_EXPOSURE_TIME_6): + self._state = data['safe_exposure_time'][EXPOSURE_TYPE_MAP[ + self._sensor_type]] diff --git a/homeassistant/components/sensor/rmvtransport.py b/homeassistant/components/sensor/rmvtransport.py new file mode 100644 index 00000000000..3d7fd2aa3b7 --- /dev/null +++ b/homeassistant/components/sensor/rmvtransport.py @@ -0,0 +1,202 @@ +""" +Support for real-time departure information for Rhein-Main public transport. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.rmvtransport/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) + +REQUIREMENTS = ['PyRMVtransport==0.0.7'] + +_LOGGER = logging.getLogger(__name__) + +CONF_NEXT_DEPARTURE = 'next_departure' + +CONF_STATION = 'station' +CONF_DESTINATIONS = 'destinations' +CONF_DIRECTIONS = 'directions' +CONF_LINES = 'lines' +CONF_PRODUCTS = 'products' +CONF_TIME_OFFSET = 'time_offset' +CONF_MAX_JOURNEYS = 'max_journeys' + +DEFAULT_NAME = 'RMV Journey' + +VALID_PRODUCTS = ['U-Bahn', 'Tram', 'Bus', 'S', 'RB', 'RE', 'EC', 'IC', 'ICE'] + +ICONS = { + 'U-Bahn': 'mdi:subway', + 'Tram': 'mdi:tram', + 'Bus': 'mdi:bus', + 'S': 'mdi:train', + 'RB': 'mdi:train', + 'RE': 'mdi:train', + 'EC': 'mdi:train', + 'IC': 'mdi:train', + 'ICE': 'mdi:train', + 'SEV': 'mdi:checkbox-blank-circle-outline', + None: 'mdi:clock' +} +ATTRIBUTION = "Data provided by opendata.rmv.de" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NEXT_DEPARTURE): [{ + vol.Required(CONF_STATION): cv.string, + vol.Optional(CONF_DESTINATIONS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_DIRECTIONS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_LINES, default=[]): + vol.All(cv.ensure_list, [cv.positive_int, cv.string]), + vol.Optional(CONF_PRODUCTS, default=VALID_PRODUCTS): + vol.All(cv.ensure_list, [vol.In(VALID_PRODUCTS)]), + vol.Optional(CONF_TIME_OFFSET, default=0): cv.positive_int, + vol.Optional(CONF_MAX_JOURNEYS, default=5): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string}] +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the RMV departure sensor.""" + sensors = [] + for next_departure in config.get(CONF_NEXT_DEPARTURE): + sensors.append( + RMVDepartureSensor( + next_departure[CONF_STATION], + next_departure.get(CONF_DESTINATIONS), + next_departure.get(CONF_DIRECTIONS), + next_departure.get(CONF_LINES), + next_departure.get(CONF_PRODUCTS), + next_departure.get(CONF_TIME_OFFSET), + next_departure.get(CONF_MAX_JOURNEYS), + next_departure.get(CONF_NAME))) + add_entities(sensors, True) + + +class RMVDepartureSensor(Entity): + """Implementation of an RMV departure sensor.""" + + def __init__(self, station, destinations, directions, + lines, products, time_offset, max_journeys, name): + """Initialize the sensor.""" + self._station = station + self._name = name + self._state = None + self.data = RMVDepartureData(station, destinations, directions, lines, + products, time_offset, max_journeys) + self._icon = ICONS[None] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return self._state is not None + + @property + def state(self): + """Return the next departure time.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + try: + return { + 'next_departures': [val for val in self.data.departures[1:]], + 'direction': self.data.departures[0].get('direction'), + 'line': self.data.departures[0].get('line'), + 'minutes': self.data.departures[0].get('minutes'), + 'departure_time': + self.data.departures[0].get('departure_time'), + 'product': self.data.departures[0].get('product'), + } + except IndexError: + return {} + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return "min" + + def update(self): + """Get the latest data and update the state.""" + self.data.update() + if not self.data.departures: + self._state = None + self._icon = ICONS[None] + return + if self._name == DEFAULT_NAME: + self._name = self.data.station + self._station = self.data.station + self._state = self.data.departures[0].get('minutes') + self._icon = ICONS[self.data.departures[0].get('product')] + + +class RMVDepartureData: + """Pull data from the opendata.rmv.de web page.""" + + def __init__(self, station_id, destinations, directions, + lines, products, time_offset, max_journeys): + """Initialize the sensor.""" + import RMVtransport + self.station = None + self._station_id = station_id + self._destinations = destinations + self._directions = directions + self._lines = lines + self._products = products + self._time_offset = time_offset + self._max_journeys = max_journeys + self.rmv = RMVtransport.RMVtransport() + self.departures = [] + + def update(self): + """Update the connection data.""" + try: + _data = self.rmv.get_departures(self._station_id, + products=self._products, + maxJourneys=50) + except ValueError: + self.departures = [] + _LOGGER.warning("Returned data not understood") + return + self.station = _data.get('station') + _deps = [] + for journey in _data['journeys']: + # find the first departure meeting the criteria + _nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION} + if self._destinations: + dest_found = False + for dest in self._destinations: + if dest in journey['stops']: + dest_found = True + _nextdep['destination'] = dest + if not dest_found: + continue + elif self._lines and journey['number'] not in self._lines: + continue + elif journey['minutes'] < self._time_offset: + continue + for attr in ['direction', 'departure_time', 'product', 'minutes']: + _nextdep[attr] = journey.get(attr, '') + _nextdep['line'] = journey.get('number', '') + _deps.append(_nextdep) + if len(_deps) > self._max_journeys: + break + self.departures = _deps diff --git a/homeassistant/components/sensor/sense.py b/homeassistant/components/sensor/sense.py index 16f4ccb9b6c..89e0d15bf48 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sense_energy==0.3.1'] +REQUIREMENTS = ['sense_energy==0.4.1'] _LOGGER = logging.getLogger(__name__) @@ -139,7 +139,12 @@ class Sense(Entity): def update(self): """Get the latest data, update state.""" - self.update_sensor() + from sense_energy import SenseAPITimeoutException + try: + self.update_sensor() + except SenseAPITimeoutException: + _LOGGER.error("Timeout retrieving data") + return if self._sensor_type == ACTIVE_TYPE: if self._is_production: diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index 541abea3091..dfc49ce6639 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.8.1'] +REQUIREMENTS = ['shodan==1.9.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 5600f906f34..e6119ab80b6 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.4.4'] +REQUIREMENTS = ['pysnmp==4.4.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/sonarr.py b/homeassistant/components/sensor/sonarr.py index 090addb5b6e..c2fd6c80663 100644 --- a/homeassistant/components/sensor/sonarr.py +++ b/homeassistant/components/sensor/sonarr.py @@ -158,8 +158,12 @@ class SonarrSensor(Entity): ) elif self.type == 'series': for show in self.data: - attributes[show['title']] = '{}/{} Episodes'.format( - show['episodeFileCount'], show['episodeCount']) + if 'episodeFileCount' not in show \ + or 'episodeCount' not in show: + attributes[show['title']] = 'N/A' + else: + attributes[show['title']] = '{}/{} Episodes'.format( + show['episodeFileCount'], show['episodeCount']) elif self.type == 'status': attributes = self.data return attributes diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index a77509c18d4..35333090910 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -97,7 +97,6 @@ class StatisticsSensor(Entity): hass.async_add_job(self._initialize_from_database) @callback - # pylint: disable=invalid-name def async_stats_sensor_state_listener(entity, old_state, new_state): """Handle the sensor state changes.""" self._unit_of_measurement = new_state.attributes.get( diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index 70c169a1b7c..023da72299b 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -31,8 +31,10 @@ CONF_DESTINATION = 'destination' CONF_ORIGIN = 'origin' CONF_INCL_FILTER = 'incl_filter' CONF_EXCL_FILTER = 'excl_filter' +CONF_REALTIME = 'realtime' DEFAULT_NAME = 'Waze Travel Time' +DEFAULT_REALTIME = True ICON = 'mdi:car' @@ -49,6 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_INCL_FILTER): cv.string, vol.Optional(CONF_EXCL_FILTER): cv.string, + vol.Optional(CONF_REALTIME, default=DEFAULT_REALTIME): cv.boolean, }) @@ -60,9 +63,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): region = config.get(CONF_REGION) incl_filter = config.get(CONF_INCL_FILTER) excl_filter = config.get(CONF_EXCL_FILTER) + realtime = config.get(CONF_REALTIME) sensor = WazeTravelTime(name, origin, destination, region, - incl_filter, excl_filter) + incl_filter, excl_filter, realtime) add_devices([sensor]) @@ -80,12 +84,13 @@ class WazeTravelTime(Entity): """Representation of a Waze travel time sensor.""" def __init__(self, name, origin, destination, region, - incl_filter, excl_filter): + incl_filter, excl_filter, realtime): """Initialize the Waze travel time sensor.""" self._name = name self._region = region self._incl_filter = incl_filter self._excl_filter = excl_filter + self._realtime = realtime self._state = None self._origin_entity_id = None self._destination_entity_id = None @@ -197,7 +202,7 @@ class WazeTravelTime(Entity): try: params = WazeRouteCalculator.WazeRouteCalculator( self._origin, self._destination, self._region) - routes = params.calc_all_routes_info() + routes = params.calc_all_routes_info(real_time=self._realtime) if self._incl_filter is not None: routes = {k: v for k, v in routes.items() if diff --git a/homeassistant/components/sensor/worxlandroid.py b/homeassistant/components/sensor/worxlandroid.py index c49ce36bd49..8963bb135e0 100644 --- a/homeassistant/components/sensor/worxlandroid.py +++ b/homeassistant/components/sensor/worxlandroid.py @@ -28,7 +28,7 @@ DEFAULT_TIMEOUT = 5 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PIN): - vol.All(vol.Coerce(int), vol.Range(min=1000, max=9999)), + vol.All(vol.Coerce(str), vol.Match(r'\d{4}')), vol.Optional(CONF_ALLOW_UNREACHABLE, default=True): cv.boolean, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 4c5592c02c2..bbc05a3aa61 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -1,5 +1,5 @@ """Component to embed Sonos.""" -from homeassistant import data_entry_flow +from homeassistant import config_entries from homeassistant.helpers import config_entry_flow @@ -15,7 +15,7 @@ async def async_setup(hass, config): if conf is not None: hass.async_create_task(hass.config_entries.flow.async_init( - DOMAIN, source=data_entry_flow.SOURCE_IMPORT)) + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) return True diff --git a/homeassistant/components/switch/amcrest.py b/homeassistant/components/switch/amcrest.py index 0b93bc98b10..cfe33562f9f 100644 --- a/homeassistant/components/switch/amcrest.py +++ b/homeassistant/components/switch/amcrest.py @@ -30,7 +30,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): all_switches = [] for setting in switches: - all_switches.append(AmcrestSwitch(setting, camera)) + all_switches.append(AmcrestSwitch(setting, camera, name)) async_add_devices(all_switches, True) @@ -38,11 +38,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AmcrestSwitch(ToggleEntity): """Representation of an Amcrest IP camera switch.""" - def __init__(self, setting, camera): + def __init__(self, setting, camera, name): """Initialize the Amcrest switch.""" self._setting = setting self._camera = camera - self._name = SWITCHES[setting][0] + self._name = '{} {}'.format(SWITCHES[setting][0], name) self._icon = SWITCHES[setting][1] self._state = None diff --git a/homeassistant/components/switch/deconz.py b/homeassistant/components/switch/deconz.py new file mode 100644 index 00000000000..d5fb22e97c4 --- /dev/null +++ b/homeassistant/components/switch/deconz.py @@ -0,0 +1,118 @@ +""" +Support for deCONZ switches. + +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, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, + POWER_PLUGS, SIRENS) +from homeassistant.components.switch import SwitchDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Old way of setting up deCONZ switches.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up switches for deCONZ component. + + Switches are based same device class as lights in deCONZ. + """ + @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)) + elif light.type in SIRENS: + entities.append(DeconzSiren(light)) + async_add_devices(entities, True) + + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_switch)) + + async_add_switch(hass.data[DATA_DECONZ].lights.values()) + + +class DeconzSwitch(SwitchDevice): + """Representation of a deCONZ switch.""" + + def __init__(self, switch): + """Set up switch and add update callback to get data from websocket.""" + self._switch = switch + + 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_ID][self.entity_id] = self._switch.deconz_id + + @callback + def async_update_callback(self, reason): + """Update the switch's state.""" + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the switch.""" + return self._switch.name + + @property + def unique_id(self): + """Return a unique identifier for this switch.""" + return self._switch.uniqueid + + @property + def available(self): + """Return True if light is available.""" + return self._switch.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + +class DeconzPowerPlug(DeconzSwitch): + """Representation of power plugs from deCONZ.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._switch.state + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {'on': True} + await self._switch.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {'on': False} + await self._switch.async_set_state(data) + + +class DeconzSiren(DeconzSwitch): + """Representation of sirens from deCONZ.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._switch.alert == 'lselect' + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {'alert': 'lselect'} + await self._switch.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {'alert': 'none'} + await self._switch.async_set_state(data) diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index b0c192cdafa..9c84584e833 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.4'] +REQUIREMENTS = ['pysnmp==4.4.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/velbus.py b/homeassistant/components/switch/velbus.py index 15090091a52..46f6e893c97 100644 --- a/homeassistant/components/switch/velbus.py +++ b/homeassistant/components/switch/velbus.py @@ -4,108 +4,42 @@ Support for Velbus switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.velbus/ """ - -import asyncio import logging -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_DEVICES -from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA -from homeassistant.components.velbus import DOMAIN -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.velbus import ( + DOMAIN as VELBUS_DOMAIN, VelbusEntity) _LOGGER = logging.getLogger(__name__) -SWITCH_SCHEMA = { - vol.Required('module'): cv.positive_int, - vol.Required('channel'): cv.positive_int, - vol.Required(CONF_NAME): cv.string -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): - vol.All(cv.ensure_list, [SWITCH_SCHEMA]) -}) - DEPENDENCIES = ['velbus'] -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Switch.""" - velbus = hass.data[DOMAIN] - devices = [] - - for switch in config[CONF_DEVICES]: - devices.append(VelbusSwitch(switch, velbus)) - add_devices(devices) - return True +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Velbus Switch platform.""" + if discovery_info is None: + return + switches = [] + for switch in discovery_info: + module = hass.data[VELBUS_DOMAIN].get_module(switch[0]) + channel = switch[1] + switches.append(VelbusSwitch(module, channel)) + async_add_devices(switches) -class VelbusSwitch(SwitchDevice): +class VelbusSwitch(VelbusEntity, SwitchDevice): """Representation of a switch.""" - def __init__(self, switch, velbus): - """Initialize a Velbus switch.""" - self._velbus = velbus - self._name = switch[CONF_NAME] - self._module = switch['module'] - self._channel = switch['channel'] - self._state = False - - @asyncio.coroutine - def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - def _init_velbus(): - """Initialize Velbus on startup.""" - self._velbus.subscribe(self._on_message) - self.get_status() - - yield from self.hass.async_add_job(_init_velbus) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.RelayStatusMessage) and \ - message.address == self._module and \ - message.channel == self._channel: - self._state = message.is_on() - self.schedule_update_ha_state() - - @property - def name(self): - """Return the display name of this switch.""" - return self._name - - @property - def should_poll(self): - """Disable polling.""" - return False - @property def is_on(self): """Return true if the switch is on.""" - return self._state + return self._module.is_on(self._channel) def turn_on(self, **kwargs): """Instruct the switch to turn on.""" - import velbus - message = velbus.SwitchRelayOnMessage() - message.set_defaults(self._module) - message.relay_channels = [self._channel] - self._velbus.send(message) + self._module.turn_on(self._channel) def turn_off(self, **kwargs): """Instruct the switch to turn off.""" - import velbus - message = velbus.SwitchRelayOffMessage() - message.set_defaults(self._module) - message.relay_channels = [self._channel] - self._velbus.send(message) - - def get_status(self): - """Retrieve current status.""" - import velbus - message = velbus.ModuleStatusRequestMessage() - message.set_defaults(self._module) - message.channels = [self._channel] - self._velbus.send(message) + self._module.turn_off(self._channel) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 1cbc81709c4..aaa64489168 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -50,6 +50,8 @@ TAHOMA_TYPES = { 'io:WindowOpenerVeluxIOComponent': 'cover', 'io:LightIOSystemSensor': 'sensor', 'rts:GarageDoor4TRTSComponent': 'switch', + 'io:VerticalExteriorAwningIOComponent': 'cover', + 'io:HorizontalAwningIOComponent': 'cover', 'rtds:RTDSSmokeSensor': 'smoke', } diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 880b3604a86..97d009626b8 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -14,12 +14,12 @@ import voluptuous as vol from homeassistant.components import group from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, SERVICE_TOGGLE, - SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON) + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, STATE_PAUSED, STATE_IDLE) from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import (ToggleEntity, Entity) from homeassistant.helpers.icon import icon_for_battery_level _LOGGER = logging.getLogger(__name__) @@ -45,6 +45,8 @@ SERVICE_RETURN_TO_BASE = 'return_to_base' SERVICE_SEND_COMMAND = 'send_command' SERVICE_SET_FAN_SPEED = 'set_fan_speed' SERVICE_START_PAUSE = 'start_pause' +SERVICE_START = 'start' +SERVICE_PAUSE = 'pause' SERVICE_STOP = 'stop' VACUUM_SERVICE_SCHEMA = vol.Schema({ @@ -65,6 +67,8 @@ SERVICE_TO_METHOD = { SERVICE_TURN_OFF: {'method': 'async_turn_off'}, SERVICE_TOGGLE: {'method': 'async_toggle'}, SERVICE_START_PAUSE: {'method': 'async_start_pause'}, + SERVICE_START: {'method': 'async_start'}, + SERVICE_PAUSE: {'method': 'async_pause'}, SERVICE_RETURN_TO_BASE: {'method': 'async_return_to_base'}, SERVICE_CLEAN_SPOT: {'method': 'async_clean_spot'}, SERVICE_LOCATE: {'method': 'async_locate'}, @@ -75,6 +79,13 @@ SERVICE_TO_METHOD = { 'schema': VACUUM_SEND_COMMAND_SERVICE_SCHEMA}, } +STATE_CLEANING = 'cleaning' +STATE_DOCKED = 'docked' +STATE_IDLE = STATE_IDLE +STATE_PAUSED = STATE_PAUSED +STATE_RETURNING = 'returning' +STATE_ERROR = 'error' + DEFAULT_NAME = 'Vacuum cleaner robot' SUPPORT_TURN_ON = 1 @@ -89,6 +100,8 @@ SUPPORT_SEND_COMMAND = 256 SUPPORT_LOCATE = 512 SUPPORT_CLEAN_SPOT = 1024 SUPPORT_MAP = 2048 +SUPPORT_STATE = 4096 +SUPPORT_START = 8192 @bind_hass @@ -147,6 +160,20 @@ def start_pause(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_START_PAUSE, data) +@bind_hass +def start(hass, entity_id=None): + """Tell all or specified vacuum to start or resume the current task.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_START, data) + + +@bind_hass +def pause(hass, entity_id=None): + """Tell all or the specified vacuum to pause the current task.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_PAUSE, data) + + @bind_hass def stop(hass, entity_id=None): """Stop all or specified vacuum.""" @@ -208,33 +235,22 @@ def async_setup(hass, config): return True -class VacuumDevice(ToggleEntity): - """Representation of a vacuum cleaner robot.""" +class _BaseVacuum(Entity): + """Representation of a base vacuum. + + Contains common properties and functions for all vacuum devices. + """ @property def supported_features(self): """Flag vacuum cleaner features that are supported.""" raise NotImplementedError() - @property - def status(self): - """Return the status of the vacuum cleaner.""" - return None - @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" return None - @property - def battery_icon(self): - """Return the battery icon for the vacuum cleaner.""" - charging = False - if self.status is not None: - charging = 'charg' in self.status.lower() - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging) - @property def fan_speed(self): """Return the fan speed of the vacuum cleaner.""" @@ -245,6 +261,94 @@ class VacuumDevice(ToggleEntity): """Get the list of available fan speed steps of the vacuum cleaner.""" raise NotImplementedError() + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + raise NotImplementedError() + + async def async_stop(self, **kwargs): + """Stop the vacuum cleaner. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job(partial(self.stop, **kwargs)) + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + raise NotImplementedError() + + async def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.return_to_base, **kwargs)) + + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + raise NotImplementedError() + + async def async_clean_spot(self, **kwargs): + """Perform a spot clean-up. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.clean_spot, **kwargs)) + + def locate(self, **kwargs): + """Locate the vacuum cleaner.""" + raise NotImplementedError() + + async def async_locate(self, **kwargs): + """Locate the vacuum cleaner. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job(partial(self.locate, **kwargs)) + + def set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + raise NotImplementedError() + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.set_fan_speed, fan_speed, **kwargs)) + + def send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + raise NotImplementedError() + + async def async_send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.send_command, command, params=params, **kwargs)) + + +class VacuumDevice(_BaseVacuum, ToggleEntity): + """Representation of a vacuum cleaner robot.""" + + @property + def status(self): + """Return the status of the vacuum cleaner.""" + return None + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + charging = False + if self.status is not None: + charging = 'charg' in self.status.lower() + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging) + @property def state_attributes(self): """Return the state attributes of the vacuum cleaner.""" @@ -267,100 +371,88 @@ class VacuumDevice(ToggleEntity): """Turn the vacuum on and start cleaning.""" raise NotImplementedError() - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the vacuum on and start cleaning. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job(partial(self.turn_on, **kwargs)) + await self.hass.async_add_executor_job( + partial(self.turn_on, **kwargs)) def turn_off(self, **kwargs): """Turn the vacuum off stopping the cleaning and returning home.""" raise NotImplementedError() - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the vacuum off stopping the cleaning and returning home. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job(partial(self.turn_off, **kwargs)) - - def return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock.""" - raise NotImplementedError() - - def async_return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(partial(self.return_to_base, **kwargs)) - - def stop(self, **kwargs): - """Stop the vacuum cleaner.""" - raise NotImplementedError() - - def async_stop(self, **kwargs): - """Stop the vacuum cleaner. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(partial(self.stop, **kwargs)) - - def clean_spot(self, **kwargs): - """Perform a spot clean-up.""" - raise NotImplementedError() - - def async_clean_spot(self, **kwargs): - """Perform a spot clean-up. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(partial(self.clean_spot, **kwargs)) - - def locate(self, **kwargs): - """Locate the vacuum cleaner.""" - raise NotImplementedError() - - def async_locate(self, **kwargs): - """Locate the vacuum cleaner. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(partial(self.locate, **kwargs)) - - def set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - raise NotImplementedError() - - def async_set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job( - partial(self.set_fan_speed, fan_speed, **kwargs)) + await self.hass.async_add_executor_job( + partial(self.turn_off, **kwargs)) def start_pause(self, **kwargs): """Start, pause or resume the cleaning task.""" raise NotImplementedError() - def async_start_pause(self, **kwargs): + async def async_start_pause(self, **kwargs): """Start, pause or resume the cleaning task. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job( + await self.hass.async_add_executor_job( partial(self.start_pause, **kwargs)) - def send_command(self, command, params=None, **kwargs): - """Send a command to a vacuum cleaner.""" + +class StateVacuumDevice(_BaseVacuum): + """Representation of a vacuum cleaner robot that supports states.""" + + @property + def state(self): + """Return the state of the vacuum cleaner.""" + return None + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + charging = bool(self.state == STATE_DOCKED) + + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging) + + @property + def state_attributes(self): + """Return the state attributes of the vacuum cleaner.""" + data = {} + + if self.battery_level is not None: + data[ATTR_BATTERY_LEVEL] = self.battery_level + data[ATTR_BATTERY_ICON] = self.battery_icon + + if self.fan_speed is not None: + data[ATTR_FAN_SPEED] = self.fan_speed + data[ATTR_FAN_SPEED_LIST] = self.fan_speed_list + + return data + + def start(self): + """Start or resume the cleaning task.""" raise NotImplementedError() - def async_send_command(self, command, params=None, **kwargs): - """Send a command to a vacuum cleaner. + async def async_start(self): + """Start or resume the cleaning task. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job( - partial(self.send_command, command, params=params, **kwargs)) + await self.hass.async_add_executor_job(self.start) + + def pause(self): + """Pause the cleaning task.""" + raise NotImplementedError() + + async def async_pause(self): + """Pause the cleaning task. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job(self.pause) diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index 45fd8de2696..5d4c6856a4d 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -10,7 +10,9 @@ from homeassistant.components.vacuum import ( ATTR_CLEANED_AREA, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, VacuumDevice) + SUPPORT_TURN_ON, SUPPORT_STATE, SUPPORT_START, STATE_CLEANING, + STATE_DOCKED, STATE_IDLE, STATE_PAUSED, STATE_RETURNING, VacuumDevice, + StateVacuumDevice) _LOGGER = logging.getLogger(__name__) @@ -28,12 +30,17 @@ SUPPORT_ALL_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \ SUPPORT_LOCATE | SUPPORT_STATUS | SUPPORT_BATTERY | \ SUPPORT_CLEAN_SPOT +SUPPORT_STATE_SERVICES = SUPPORT_STATE | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | \ + SUPPORT_BATTERY | SUPPORT_CLEAN_SPOT | SUPPORT_START + FAN_SPEEDS = ['min', 'medium', 'high', 'max'] DEMO_VACUUM_COMPLETE = '0_Ground_floor' DEMO_VACUUM_MOST = '1_First_floor' DEMO_VACUUM_BASIC = '2_Second_floor' DEMO_VACUUM_MINIMAL = '3_Third_floor' DEMO_VACUUM_NONE = '4_Fourth_floor' +DEMO_VACUUM_STATE = '5_Fifth_floor' def setup_platform(hass, config, add_devices, discovery_info=None): @@ -44,6 +51,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): DemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), DemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), DemoVacuum(DEMO_VACUUM_NONE, 0), + StateDemoVacuum(DEMO_VACUUM_STATE), ]) @@ -204,3 +212,125 @@ class DemoVacuum(VacuumDevice): self._status = 'Executing {}({})'.format(command, params) self._state = True self.schedule_update_ha_state() + + +class StateDemoVacuum(StateVacuumDevice): + """Representation of a demo vacuum supporting states.""" + + def __init__(self, name): + """Initialize the vacuum.""" + self._name = name + self._supported_features = SUPPORT_STATE_SERVICES + self._state = STATE_DOCKED + self._fan_speed = FAN_SPEEDS[1] + self._cleaned_area = 0 + self._battery_level = 100 + + @property + def name(self): + """Return the name of the vacuum.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo vacuum.""" + return False + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + @property + def state(self): + """Return the current state of the vacuum.""" + return self._state + + @property + def battery_level(self): + """Return the current battery level of the vacuum.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return max(0, min(100, self._battery_level)) + + @property + def fan_speed(self): + """Return the current fan speed of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + return self._fan_speed + + @property + def fan_speed_list(self): + """Return the list of supported fan speeds.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + return FAN_SPEEDS + + @property + def device_state_attributes(self): + """Return device state attributes.""" + return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)} + + def start(self): + """Start or resume the cleaning task.""" + if self.supported_features & SUPPORT_START == 0: + return + + if self._state != STATE_CLEANING: + self._state = STATE_CLEANING + self._cleaned_area += 1.32 + self._battery_level -= 1 + self.schedule_update_ha_state() + + def pause(self): + """Pause the cleaning task.""" + if self.supported_features & SUPPORT_PAUSE == 0: + return + + if self._state == STATE_CLEANING: + self._state = STATE_PAUSED + self.schedule_update_ha_state() + + def stop(self, **kwargs): + """Stop the cleaning task, do not return to dock.""" + if self.supported_features & SUPPORT_STOP == 0: + return + + self._state = STATE_IDLE + self.schedule_update_ha_state() + + def return_to_base(self, **kwargs): + """Return dock to charging base.""" + if self.supported_features & SUPPORT_RETURN_HOME == 0: + return + + self._state = STATE_RETURNING + self.schedule_update_ha_state() + + self.hass.loop.call_later(30, self.__set_state_to_dock) + + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + if self.supported_features & SUPPORT_CLEAN_SPOT == 0: + return + + self._state = STATE_CLEANING + self._cleaned_area += 1.32 + self._battery_level -= 1 + self.schedule_update_ha_state() + + def set_fan_speed(self, fan_speed, **kwargs): + """Set the vacuum's fan speed.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + if fan_speed in self.fan_speed_list: + self._fan_speed = fan_speed + self.schedule_update_ha_state() + + def __set_state_to_dock(self): + self._state = STATE_DOCKED + self.schedule_update_ha_state() diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index 863157074bc..6e40b3d67fc 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -35,6 +35,20 @@ start_pause: description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' +start: + description: Start or resume the cleaning task. + fields: + entity_id: + description: Name of the vacuum entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +pause: + description: Pause the cleaning task. + fields: + entity_id: + description: Name of the vacuum entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + return_to_base: description: Tell the vacuum cleaner to return to its dock. fields: diff --git a/homeassistant/components/velbus.py b/homeassistant/components/velbus.py index ff2db955d31..8c944916905 100644 --- a/homeassistant/components/velbus.py +++ b/homeassistant/components/velbus.py @@ -9,8 +9,10 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_PORT +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-velbus==2.0.11'] +REQUIREMENTS = ['python-velbus==2.0.17'] _LOGGER = logging.getLogger(__name__) @@ -26,18 +28,76 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +async def async_setup(hass, config): """Set up the Velbus platform.""" import velbus port = config[DOMAIN].get(CONF_PORT) - connection = velbus.VelbusUSBConnection(port) - controller = velbus.Controller(connection) + controller = velbus.Controller(port) + hass.data[DOMAIN] = controller def stop_velbus(event): """Disconnect from serial port.""" _LOGGER.debug("Shutting down ") - connection.stop() + controller.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_velbus) + + def callback(): + modules = controller.get_modules() + discovery_info = { + 'switch': [], + 'binary_sensor': [] + } + for module in modules: + for channel in range(1, module.number_of_channels() + 1): + for category in discovery_info: + if category in module.get_categories(channel): + discovery_info[category].append(( + module.get_module_address(), + channel + )) + load_platform(hass, 'switch', DOMAIN, + discovery_info['switch'], config) + load_platform(hass, 'binary_sensor', DOMAIN, + discovery_info['binary_sensor'], config) + + controller.scan(callback) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_velbus) return True + + +class VelbusEntity(Entity): + """Representation of a Velbus entity.""" + + def __init__(self, module, channel): + """Initialize a Velbus entity.""" + self._module = module + self._channel = channel + + @property + def unique_id(self): + """Get unique ID.""" + serial = 0 + if self._module.serial == 0: + serial = self._module.get_module_address() + else: + serial = self._module.serial + return "{}-{}".format(serial, self._channel) + + @property + def name(self): + """Return the display name of this entity.""" + return self._module.get_name(self._channel) + + @property + def should_poll(self): + """Disable polling.""" + return False + + async def async_added_to_hass(self): + """Add listener for state changes.""" + self._module.on_status_update(self._channel, self._on_update) + + def _on_update(self, state): + self.schedule_update_ha_state() diff --git a/homeassistant/components/weather/openweathermap.py b/homeassistant/components/weather/openweathermap.py index 334948b67fb..46a0b3ecc14 100644 --- a/homeassistant/components/weather/openweathermap.py +++ b/homeassistant/components/weather/openweathermap.py @@ -131,7 +131,7 @@ class OpenWeatherMapWeather(WeatherEntity): @property def wind_speed(self): """Return the wind speed.""" - return self.data.get_wind().get('speed') + return round(self.data.get_wind().get('speed') * 3.6, 2) @property def wind_bearing(self): @@ -173,7 +173,10 @@ class OpenWeatherMapWeather(WeatherEntity): ATTR_FORECAST_TEMP: entry.get_temperature('celsius').get('temp'), ATTR_FORECAST_PRECIPITATION: - entry.get_rain().get('3h'), + (round(entry.get_rain().get('3h'), 1) + if entry.get_rain().get('3h') is not None + and (round(entry.get_rain().get('3h'), 1) > 0) + else None), ATTR_FORECAST_CONDITION: [k for k, v in CONDITION_CLASSES.items() if entry.get_weather_code() in v][0] diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index ed478550c7a..532f3672df4 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -352,11 +352,12 @@ class ActiveConnection: if self.hass.auth.active and 'access_token' in msg: self.debug("Received access_token") - token = self.hass.auth.async_get_access_token( - msg['access_token']) - authenticated = token is not None + refresh_token = \ + await self.hass.auth.async_validate_access_token( + msg['access_token']) + authenticated = refresh_token is not None if authenticated: - request['hass_user'] = token.refresh_token.user + request['hass_user'] = refresh_token.user elif ((not self.hass.auth.active or self.hass.auth.support_legacy) and @@ -519,8 +520,12 @@ def handle_call_service(hass, connection, msg): """ async def call_service_helper(msg): """Call a service and fire complete message.""" + blocking = True + if (msg['domain'] == 'homeassistant' and + msg['service'] in ['restart', 'stop']): + blocking = False await hass.services.async_call( - msg['domain'], msg['service'], msg.get('service_data'), True, + msg['domain'], msg['service'], msg.get('service_data'), blocking, connection.context(msg)) connection.send_message_outside(result_message(msg['id'])) diff --git a/homeassistant/components/zone/config_flow.py b/homeassistant/components/zone/config_flow.py index 5ec955a48d9..01577de4c8f 100644 --- a/homeassistant/components/zone/config_flow.py +++ b/homeassistant/components/zone/config_flow.py @@ -29,6 +29,10 @@ class ZoneFlowHandler(data_entry_flow.FlowHandler): """Initialize zone configuration flow.""" pass + 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 flow start.""" errors = {} diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index e540259edd5..8cf69e72702 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -35,7 +35,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.3'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zwave/discovery_schemas.py b/homeassistant/components/zwave/discovery_schemas.py index f88b911a6a5..2a4e42ab92c 100644 --- a/homeassistant/components/zwave/discovery_schemas.py +++ b/homeassistant/components/zwave/discovery_schemas.py @@ -175,6 +175,7 @@ DISCOVERY_SCHEMAS = [ {const.DISC_COMPONENT: 'lock', const.DISC_GENERIC_DEVICE_CLASS: [const.GENERIC_TYPE_ENTRY_CONTROL], const.DISC_SPECIFIC_DEVICE_CLASS: [ + const.SPECIFIC_TYPE_DOOR_LOCK, const.SPECIFIC_TYPE_ADVANCED_DOOR_LOCK, const.SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK, const.SPECIFIC_TYPE_SECURE_LOCKBOX], diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 8e2bb3fa5df..b2e8389e449 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -24,20 +24,24 @@ Before instantiating the handler, Home Assistant will make sure to load all dependencies and install the requirements of the component. At a minimum, each config flow will have to define a version number and the -'init' step. +'user' step. @config_entries.HANDLERS.register(DOMAIN) - class ExampleConfigFlow(config_entries.FlowHandler): + class ExampleConfigFlow(data_entry_flow.FlowHandler): VERSION = 1 - async def async_step_init(self, user_input=None): + async def async_step_user(self, user_input=None): … -The 'init' step is the first step of a flow and is called when a user +The 'user' step is the first step of a flow and is called when a user starts a new flow. Each step has three different possible results: "Show Form", "Abort" and "Create Entry". +> Note: prior 0.76, the default step is 'init' step, some config flows still +keep 'init' step to avoid break localization. All new config flow should use +'user' step. + ### Show Form This will show a form to the user to fill in. You define the current step, @@ -50,7 +54,7 @@ a title, a description and the schema of the data that needs to be returned. data_schema[vol.Required('password')] = str return self.async_show_form( - step_id='init', + step_id='user', title='Account Info', data_schema=vol.Schema(data_schema) ) @@ -97,10 +101,10 @@ Assistant, a success message is shown to the user and the flow is finished. You might want to initialize a config flow programmatically. For example, if we discover a device on the network that requires user interaction to finish setup. To do so, pass a source parameter and optional user input to the init -step: +method: await hass.config_entries.flow.async_init( - 'hue', source='discovery', data=discovery_info) + 'hue', context={'source': 'discovery'}, data=discovery_info) The config flow handler will need to add a step to support the source. The step should follow the same return values as a normal step. @@ -113,7 +117,7 @@ the flow from the config panel. import logging import uuid -from typing import Set, Optional # noqa pylint: disable=unused-import +from typing import Set, Optional, List # noqa pylint: disable=unused-import from homeassistant import data_entry_flow from homeassistant.core import callback, HomeAssistant @@ -123,6 +127,11 @@ from homeassistant.util.decorator import Registry _LOGGER = logging.getLogger(__name__) + +SOURCE_USER = 'user' +SOURCE_DISCOVERY = 'discovery' +SOURCE_IMPORT = 'import' + HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ @@ -151,8 +160,8 @@ ENTRY_STATE_FAILED_UNLOAD = 'failed_unload' DISCOVERY_NOTIFICATION_ID = 'config_entry_discovery' DISCOVERY_SOURCES = ( - data_entry_flow.SOURCE_DISCOVERY, - data_entry_flow.SOURCE_IMPORT, + SOURCE_DISCOVERY, + SOURCE_IMPORT, ) EVENT_FLOW_DISCOVERED = 'config_entry_discovered' @@ -270,19 +279,19 @@ class ConfigEntries: An instance of this object is available via `hass.config_entries`. """ - def __init__(self, hass, hass_config): + def __init__(self, hass: HomeAssistant, hass_config: dict) -> None: """Initialize the entry manager.""" self.hass = hass self.flow = data_entry_flow.FlowManager( hass, self._async_create_flow, self._async_finish_flow) self._hass_config = hass_config - self._entries = None + self._entries = [] # type: List[ConfigEntry] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) @callback - def async_domains(self): + def async_domains(self) -> List[str]: """Return domains for which we have entries.""" - seen = set() # type: Set[ConfigEntry] + seen = set() # type: Set[str] result = [] for entry in self._entries: @@ -293,7 +302,7 @@ class ConfigEntries: return result @callback - def async_entries(self, domain=None): + def async_entries(self, domain: str = None) -> List[ConfigEntry]: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries) @@ -319,7 +328,7 @@ class ConfigEntries: 'require_restart': not unloaded } - async def async_load(self): + async def async_load(self) -> None: """Handle loading the config.""" # Migrating for config entries stored before 0.73 config = await self.hass.helpers.storage.async_migrator( @@ -363,10 +372,10 @@ class ConfigEntries: return await entry.async_unload( self.hass, component=getattr(self.hass.components, component)) - async def _async_finish_flow(self, result): + async def _async_finish_flow(self, context, result): """Finish a config flow and add an entry.""" # If no discovery config entries in progress, remove notification. - if not any(ent['source'] in DISCOVERY_SOURCES for ent + if not any(ent['context']['source'] in DISCOVERY_SOURCES for ent in self.hass.config_entries.flow.async_progress()): self.hass.components.persistent_notification.async_dismiss( DISCOVERY_NOTIFICATION_ID) @@ -379,7 +388,7 @@ class ConfigEntries: domain=result['handler'], title=result['title'], data=result['data'], - source=result['source'], + source=context['source'], ) self._entries.append(entry) await self._async_schedule_save() @@ -394,22 +403,24 @@ class ConfigEntries: self.hass, entry.domain, self._hass_config) # Return Entry if they not from a discovery request - if result['source'] not in DISCOVERY_SOURCES: + if context['source'] not in DISCOVERY_SOURCES: return entry return entry - async def _async_create_flow(self, handler, *, source, data): + async def _async_create_flow(self, handler_key, *, context, data): """Create a flow for specified handler. Handler key is the domain of the component that we want to setup. """ - component = getattr(self.hass.components, handler) - handler = HANDLERS.get(handler) + component = getattr(self.hass.components, handler_key) + handler = HANDLERS.get(handler_key) if handler is None: raise data_entry_flow.UnknownHandler + source = context['source'] + # Make sure requirements and dependencies of component are resolved await async_process_deps_reqs( self.hass, self._hass_config, handler, component) @@ -424,7 +435,9 @@ class ConfigEntries: notification_id=DISCOVERY_NOTIFICATION_ID ) - return handler() + flow = handler() + flow.init_step = source + return flow async def _async_schedule_save(self): """Save the entity registry to a file.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index edc4d87421e..5a481e0a8c1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 75 -PATCH_VERSION = '3' +MINOR_VERSION = 76 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) diff --git a/homeassistant/core.py b/homeassistant/core.py index b17df2c11fe..2b7a2479471 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -423,7 +423,8 @@ class Event: self.event_type == other.event_type and self.data == other.data and self.origin == other.origin and - self.time_fired == other.time_fired) + self.time_fired == other.time_fired and + self.context == other.context) class EventBus: @@ -695,7 +696,8 @@ class State: return (self.__class__ == other.__class__ and # type: ignore self.entity_id == other.entity_id and self.state == other.state and - self.attributes == other.attributes) + self.attributes == other.attributes and + self.context == other.context) def __repr__(self) -> str: """Return the representation of the states.""" @@ -1143,8 +1145,8 @@ class Config: # List of loaded components self.components = set() # type: set - # Remote.API object pointing at local API - self.api = None + # API (HTTP) server configuration + self.api = None # type: Optional[Any] # Directory that holds the configuration self.config_dir = None # type: Optional[str] diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index f010ada02f3..f820911e396 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -2,16 +2,12 @@ import logging import uuid import voluptuous as vol -from typing import Dict, Any, Callable, List, Optional # noqa pylint: disable=unused-import +from typing import Dict, Any, Callable, Hashable, List, Optional # noqa pylint: disable=unused-import from .core import callback, HomeAssistant from .exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) -SOURCE_USER = 'user' -SOURCE_DISCOVERY = 'discovery' -SOURCE_IMPORT = 'import' - RESULT_TYPE_FORM = 'form' RESULT_TYPE_CREATE_ENTRY = 'create_entry' RESULT_TYPE_ABORT = 'abort' @@ -50,25 +46,21 @@ class FlowManager: return [{ 'flow_id': flow.flow_id, 'handler': flow.handler, - 'source': flow.source, + 'context': flow.context, } for flow in self._progress.values()] - async def async_init(self, handler: Callable, *, source: str = SOURCE_USER, - data: str = None) -> Any: + async def async_init(self, handler: Hashable, *, context: Dict = None, + data: Any = None) -> Any: """Start a configuration flow.""" - flow = await self._async_create_flow(handler, source=source, data=data) + flow = await self._async_create_flow( + handler, context=context, data=data) flow.hass = self.hass flow.handler = handler flow.flow_id = uuid.uuid4().hex - flow.source = source + flow.context = context self._progress[flow.flow_id] = flow - if source == SOURCE_USER: - step = 'init' - else: - step = source - - return await self._async_handle_step(flow, step, data) + return await self._async_handle_step(flow, flow.init_step, data) async def async_configure( self, flow_id: str, user_input: str = None) -> Any: @@ -117,7 +109,7 @@ class FlowManager: self._progress.pop(flow.flow_id) # We pass a copy of the result because we're mutating our version - entry = await self._async_finish_flow(dict(result)) + entry = await self._async_finish_flow(flow.context, dict(result)) if result['type'] == RESULT_TYPE_CREATE_ENTRY: result['result'] = entry @@ -131,8 +123,11 @@ class FlowHandler: flow_id = None hass = None handler = None - source = SOURCE_USER cur_step = None + context = None + + # Set by _async_create_flow callback + init_step = 'init' # Set by developer VERSION = 1 @@ -162,7 +157,6 @@ class FlowHandler: 'handler': self.handler, 'title': title, 'data': data, - 'source': self.source, } @callback diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 6f51d9aca2c..e17d5071c6a 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -22,7 +22,7 @@ class DiscoveryFlowHandler(data_entry_flow.FlowHandler): self._title = title self._discovery_function = discovery_function - async def async_step_init(self, user_input=None): + async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if self._async_current_entries(): return self.async_abort( diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 4f412eb58e7..378febf8f6d 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -2,7 +2,7 @@ import voluptuous as vol -from homeassistant import data_entry_flow +from homeassistant import data_entry_flow, config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator @@ -53,7 +53,8 @@ class FlowManagerIndexView(_BaseFlowManagerView): handler = data['handler'] try: - result = await self._flow_mgr.async_init(handler) + result = await self._flow_mgr.async_init( + handler, context={'source': config_entries.SOURCE_USER}) except data_entry_flow.UnknownHandler: return self.json_message('Invalid handler specified', 404) except data_entry_flow.UnknownStep: diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py index 5caa6b93131..77739f8adab 100644 --- a/homeassistant/helpers/entity_values.py +++ b/homeassistant/helpers/entity_values.py @@ -2,6 +2,7 @@ from collections import OrderedDict import fnmatch import re +from typing import Dict from homeassistant.core import split_entity_id @@ -9,7 +10,8 @@ from homeassistant.core import split_entity_id class EntityValues: """Class to store entity id based values.""" - def __init__(self, exact=None, domain=None, glob=None): + def __init__(self, exact: Dict = None, domain: Dict = None, + glob: Dict = None) -> None: """Initialize an EntityConfigDict.""" self._cache = {} self._exact = exact diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 3ea52388d33..824b32177cd 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -3,7 +3,7 @@ import logging import signal import sys -from homeassistant.core import callback +from homeassistant.core import callback, HomeAssistant from homeassistant.const import RESTART_EXIT_CODE from homeassistant.loader import bind_hass @@ -12,13 +12,13 @@ _LOGGER = logging.getLogger(__name__) @callback @bind_hass -def async_register_signal_handling(hass): +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): """Wrap signal handling.""" - hass.async_add_job(hass.async_stop(exit_code)) + hass.async_create_task(hass.async_stop(exit_code)) try: hass.loop.add_signal_handler( diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ea620c9bccd..d0d3fb457b1 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -142,7 +142,6 @@ class Template: self.hass.loop, self.async_render_with_possible_json_value, value, error_value).result() - # pylint: disable=invalid-name def async_render_with_possible_json_value(self, value, error_value=_SENTINEL): """Render template with value exposed. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 29e10838f21..26628d7fe62 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,6 +4,8 @@ async_timeout==3.0.0 attrs==18.1.0 certifi>=2018.04.16 jinja2>=2.10 +PyJWT==1.6.4 +cryptography==2.3.1 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 313f98a890c..c254dd500f7 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -48,6 +48,7 @@ class API: port: Optional[int] = SERVER_PORT, use_ssl: bool = False) -> None: """Init the API.""" + _LOGGER.warning('This class is deprecated and will be removed in 0.77') self.host = host self.port = port self.api_password = api_password diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index b73ec4e184e..b9b5e137d5c 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -3,7 +3,7 @@ import asyncio from functools import partial import logging import os -from typing import List, Dict, Optional +from typing import Any, Dict, List, Optional import homeassistant.util.package as pkg_util from homeassistant.core import HomeAssistant @@ -37,7 +37,7 @@ async def async_process_requirements(hass: HomeAssistant, name: str, return True -def pip_kwargs(config_dir: Optional[str]) -> Dict[str, str]: +def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]: """Return keyword arguments for PIP install.""" kwargs = { 'constraints': os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE) diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 331b9992627..98de59f2da1 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -78,7 +78,6 @@ async def async_million_events(hass): @benchmark -# pylint: disable=invalid-name async def async_million_time_changed_helper(hass): """Run a million events through time changed helper.""" count = 0 @@ -109,7 +108,6 @@ async def async_million_time_changed_helper(hass): @benchmark -# pylint: disable=invalid-name async def async_million_state_changed_helper(hass): """Run a million events through state changed helper.""" count = 0 diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 392c5986c89..b78395cdb0d 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -13,7 +13,7 @@ def client_context() -> ssl.SSLContext: return context -def server_context() -> ssl.SSLContext: +def server_context_modern() -> ssl.SSLContext: """Return an SSL context following the Mozilla recommendations. TLS configuration follows the best-practice guidelines specified here: @@ -37,4 +37,58 @@ def server_context() -> ssl.SSLContext: "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:" "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" ) + + return context + + +def server_context_intermediate() -> ssl.SSLContext: + """Return an SSL context following the Mozilla recommendations. + + TLS configuration follows the best-practice guidelines specified here: + https://wiki.mozilla.org/Security/Server_Side_TLS + Intermediate guidelines are followed. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS) # pylint: disable=no-member + + context.options |= ( + ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | + ssl.OP_CIPHER_SERVER_PREFERENCE + ) + if hasattr(ssl, 'OP_NO_COMPRESSION'): + context.options |= ssl.OP_NO_COMPRESSION + + context.set_ciphers( + "ECDHE-ECDSA-CHACHA20-POLY1305:" + "ECDHE-RSA-CHACHA20-POLY1305:" + "ECDHE-ECDSA-AES128-GCM-SHA256:" + "ECDHE-RSA-AES128-GCM-SHA256:" + "ECDHE-ECDSA-AES256-GCM-SHA384:" + "ECDHE-RSA-AES256-GCM-SHA384:" + "DHE-RSA-AES128-GCM-SHA256:" + "DHE-RSA-AES256-GCM-SHA384:" + "ECDHE-ECDSA-AES128-SHA256:" + "ECDHE-RSA-AES128-SHA256:" + "ECDHE-ECDSA-AES128-SHA:" + "ECDHE-RSA-AES256-SHA384:" + "ECDHE-RSA-AES128-SHA:" + "ECDHE-ECDSA-AES256-SHA384:" + "ECDHE-ECDSA-AES256-SHA:" + "ECDHE-RSA-AES256-SHA:" + "DHE-RSA-AES128-SHA256:" + "DHE-RSA-AES128-SHA:" + "DHE-RSA-AES256-SHA256:" + "DHE-RSA-AES256-SHA:" + "ECDHE-ECDSA-DES-CBC3-SHA:" + "ECDHE-RSA-DES-CBC3-SHA:" + "EDH-RSA-DES-CBC3-SHA:" + "AES128-GCM-SHA256:" + "AES256-GCM-SHA384:" + "AES128-SHA256:" + "AES256-SHA256:" + "AES128-SHA:" + "AES256-SHA:" + "DES-CBC3-SHA:" + "!DSS" + ) + return context diff --git a/mypy.ini b/mypy.ini index c92786e643f..875aec5eda7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,6 @@ [mypy] check_untyped_defs = true +disallow_untyped_calls = true follow_imports = silent ignore_missing_imports = true warn_incomplete_stub = true @@ -16,4 +17,5 @@ disallow_untyped_defs = false [mypy-homeassistant.util.yaml] warn_return_any = false +disallow_untyped_calls = false diff --git a/pylintrc b/pylintrc index 00bc6582f3a..b72502248d7 100644 --- a/pylintrc +++ b/pylintrc @@ -11,7 +11,6 @@ # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise -# useless-return - https://github.com/PyCQA/pylint/issues/2300 # not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 disable= abstract-class-little-used, @@ -33,8 +32,7 @@ disable= too-many-public-methods, too-many-return-statements, too-many-statements, - unused-argument, - useless-return + unused-argument [REPORTS] reports=no diff --git a/requirements_all.txt b/requirements_all.txt index 2da9e1a0fec..d65ef4fce18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,6 +5,8 @@ async_timeout==3.0.0 attrs==18.1.0 certifi>=2018.04.16 jinja2>=2.10 +PyJWT==1.6.4 +cryptography==2.3.1 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 @@ -33,20 +35,20 @@ DoorBirdPy==0.1.3 HAP-python==2.2.2 # homeassistant.components.notify.mastodon -Mastodon.py==1.3.0 +Mastodon.py==1.3.1 # homeassistant.components.isy994 PyISY==1.1.0 -# homeassistant.components.notify.html5 -PyJWT==1.6.0 - # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 # homeassistant.components.arduino PyMata==2.14 +# homeassistant.components.sensor.rmvtransport +PyRMVtransport==0.0.7 + # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.9.5 @@ -135,6 +137,9 @@ apns2==0.3.0 # homeassistant.components.asterisk_mbox asterisk_mbox==0.4.0 +# homeassistant.components.media_player.dlna_dmr +async-upnp-client==0.12.3 + # homeassistant.components.light.avion # avion==0.7 @@ -205,6 +210,9 @@ brunt==0.1.2 # homeassistant.components.device_tracker.bluetooth_tracker bt_proximity==0.1.2 +# homeassistant.components.device_tracker.bt_home_hub_5 +bthomehub5-devicelist==0.1.1 + # homeassistant.components.sensor.buienradar # homeassistant.components.weather.buienradar buienradar==0.91 @@ -308,6 +316,9 @@ enocean==0.40 # homeassistant.components.sensor.envirophat # envirophat==0.0.6 +# homeassistant.components.sensor.enphase_envoy +envoy_reader==0.1 + # homeassistant.components.sensor.season ephem==3.7.6.0 @@ -418,10 +429,10 @@ hipnotify==1.0.8 hole==0.3.0 # homeassistant.components.binary_sensor.workday -holidays==0.9.5 +holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180804.0 +home-assistant-frontend==20180816.1 # homeassistant.components.homekit_controller # homekit==0.10 @@ -524,7 +535,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==2.0.7 +locationsharinglib==2.0.11 # homeassistant.components.sensor.luftdaten luftdaten==0.2.0 @@ -579,11 +590,14 @@ nad_receiver==0.0.9 # homeassistant.components.light.nanoleaf_aurora nanoleaf==0.4.1 +# homeassistant.components.device_tracker.keenetic_ndms2 +ndms2_client==0.0.3 + # homeassistant.components.sensor.netdata netdata==0.1.2 # homeassistant.components.discovery -netdisco==1.5.0 +netdisco==2.0.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -694,7 +708,7 @@ pushetta==1.0.15 pwmled==1.2.1 # homeassistant.components.august -py-august==0.4.0 +py-august==0.6.0 # homeassistant.components.canary py-canary==0.5.0 @@ -790,7 +804,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==42 +pydeconz==43 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -892,7 +906,7 @@ pykwb==0.0.8 pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm -pylast==2.3.0 +pylast==2.4.0 # homeassistant.components.media_player.lg_netcast pylgnetcast-homeassistant==0.2.0.dev0 @@ -917,7 +931,7 @@ pylutron==0.1.0 pymailgunner==1.4 # homeassistant.components.media_player.mediaroom -pymediaroom==0.6.3 +pymediaroom==0.6.4 # homeassistant.components.media_player.xiaomi_tv pymitv==1.4.0 @@ -938,13 +952,13 @@ pymusiccast==0.1.6 pymyq==0.0.11 # homeassistant.components.mysensors -pymysensors==0.16.0 +pymysensors==0.17.0 # homeassistant.components.lock.nello pynello==1.5.1 # homeassistant.components.device_tracker.netgear -pynetgear==0.4.0 +pynetgear==0.4.1 # homeassistant.components.switch.netio pynetio==0.1.6 @@ -959,6 +973,9 @@ pynut2==2.1.2 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 +# homeassistant.components.openuv +pyopenuv==1.0.1 + # homeassistant.components.iota pyota==2.0.5 @@ -969,6 +986,9 @@ pyotp==2.2.6 # homeassistant.components.weather.openweathermap pyowm==2.9.0 +# homeassistant.components.media_player.pjlink +pypjlink2==1.2.0 + # homeassistant.components.sensor.pollen pypollencom==2.1.0 @@ -1002,7 +1022,7 @@ pysma==0.2 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.4 +pysnmp==4.4.5 # homeassistant.components.notify.stride pystride==0.1.7 @@ -1108,7 +1128,7 @@ python-telegram-bot==10.1.0 python-twitch==1.3.0 # homeassistant.components.velbus -python-velbus==2.0.11 +python-velbus==2.0.17 # homeassistant.components.media_player.vlc python-vlc==1.1.2 @@ -1120,7 +1140,7 @@ python-wink==1.9.1 python_opendata_transport==0.1.3 # homeassistant.components.zwave -python_openzwave==0.4.3 +python_openzwave==0.4.9 # homeassistant.components.egardia pythonegardia==1.0.39 @@ -1183,7 +1203,7 @@ qnapstats==0.2.6 rachiopy==0.1.3 # homeassistant.components.climate.radiotherm -radiotherm==1.3 +radiotherm==1.4.1 # homeassistant.components.raincloud raincloudy==0.0.5 @@ -1203,6 +1223,9 @@ rflink==0.0.37 # homeassistant.components.ring ring_doorbell==0.2.1 +# homeassistant.components.device_tracker.ritassist +ritassist==0.5 + # homeassistant.components.notify.rocketchat rocketchat-API==0.6.1 @@ -1241,13 +1264,13 @@ sendgrid==5.4.1 sense-hat==2.2.0 # homeassistant.components.sensor.sense -sense_energy==0.3.1 +sense_energy==0.4.1 # homeassistant.components.media_player.aquostv sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.8.1 +shodan==1.9.0 # homeassistant.components.notify.simplepush simplepush==1.1.4 @@ -1390,7 +1413,7 @@ uvcclient==0.10.1 venstarcolortouch==0.6 # homeassistant.components.config.config_entries -voluptuous-serialize==1 +voluptuous-serialize==2.0.0 # homeassistant.components.volvooncall volvooncall==0.4.0 @@ -1463,7 +1486,7 @@ yeelight==0.4.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.07.29 +youtube_dl==2018.08.04 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_docs.txt b/requirements_docs.txt index 0556b35fc08..a7436cad2fc 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.7.5 +Sphinx==1.7.6 sphinx-autodoc-typehints==1.3.0 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test.txt b/requirements_test.txt index 225958a722c..5c2bd3404ed 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,17 +1,17 @@ # linters such as flake8 and pylint should be pinned, as new releases # make new things fail. Manually update these pins when pulling in a # new version -asynctest==0.12.1 +asynctest==0.12.2 coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 mypy==0.620 pydocstyle==1.1.1 -pylint==2.0.1 +pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 -pytest-timeout==1.3.0 -pytest==3.6.3 -requests_mock==1.5 +pytest-timeout==1.3.1 +pytest==3.7.1 +requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88b228c291c..8b153494025 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2,27 +2,27 @@ # linters such as flake8 and pylint should be pinned, as new releases # make new things fail. Manually update these pins when pulling in a # new version -asynctest==0.12.1 +asynctest==0.12.2 coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 mypy==0.620 pydocstyle==1.1.1 -pylint==2.0.1 +pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 -pytest-timeout==1.3.0 -pytest==3.6.3 -requests_mock==1.5 +pytest-timeout==1.3.1 +pytest==3.7.1 +requests_mock==1.5.2 # homeassistant.components.homekit HAP-python==2.2.2 -# homeassistant.components.notify.html5 -PyJWT==1.6.0 +# homeassistant.components.sensor.rmvtransport +PyRMVtransport==0.0.7 # homeassistant.components.sonos SoCo==0.14 @@ -78,10 +78,10 @@ haversine==0.4.5 hbmqtt==0.9.2 # homeassistant.components.binary_sensor.workday -holidays==0.9.5 +holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180804.0 +home-assistant-frontend==20180816.1 # homeassistant.components.homematicip_cloud homematicip==0.9.8 @@ -136,7 +136,7 @@ py-canary==0.5.0 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==42 +pydeconz==43 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -206,7 +206,7 @@ statsd==3.2.1 uvcclient==0.10.1 # homeassistant.components.config.config_entries -voluptuous-serialize==1 +voluptuous-serialize==2.0.0 # homeassistant.components.vultr vultr==0.1.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 28c96e737ff..7652d29086b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -77,6 +77,7 @@ TEST_REQUIREMENTS = ( 'pymonoprice', 'pynx584', 'pyqwikswitch', + 'PyRMVtransport', 'python-forecastio', 'python-nest', 'pytradfri\[async\]', diff --git a/script/translations_upload_merge.py b/script/translations_upload_merge.py index 450a4c9ba0f..ce0a14c85e6 100755 --- a/script/translations_upload_merge.py +++ b/script/translations_upload_merge.py @@ -57,7 +57,7 @@ def get_translation_dict(translations, component, platform): if not component: return translations['component'] - if component not in translations: + if component not in translations['component']: translations['component'][component] = {} if not platform: diff --git a/setup.py b/setup.py index b319df9067d..7484dc286e6 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,9 @@ REQUIRES = [ 'attrs==18.1.0', 'certifi>=2018.04.16', 'jinja2>=2.10', + 'PyJWT==1.6.4', + # PyJWT has loose dependency. We want the latest one. + 'cryptography==2.3.1', 'pip>=8.0.3', 'pytz>=2018.04', 'pyyaml>=3.13,<4', diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index cad4bbdbd71..da5daca7cf6 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -199,9 +199,7 @@ async def test_saving_loading(hass, hass_storage): }) user = await manager.async_get_or_create_user(step['result']) await manager.async_activate_user(user) - refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) - - manager.async_create_access_token(refresh_token) + await manager.async_create_refresh_token(user, CLIENT_ID) await flush_store(manager._store._store) @@ -211,30 +209,6 @@ async def test_saving_loading(hass, hass_storage): assert users[0] == user -def test_access_token_expired(): - """Test that the expired property on access tokens work.""" - refresh_token = auth_models.RefreshToken( - user=None, - client_id='bla' - ) - - access_token = auth_models.AccessToken( - refresh_token=refresh_token - ) - - assert access_token.expired is False - - with patch('homeassistant.util.dt.utcnow', - return_value=dt_util.utcnow() + - auth_const.ACCESS_TOKEN_EXPIRATION): - assert access_token.expired is True - - almost_exp = \ - dt_util.utcnow() + auth_const.ACCESS_TOKEN_EXPIRATION - timedelta(1) - with patch('homeassistant.util.dt.utcnow', return_value=almost_exp): - assert access_token.expired is False - - async def test_cannot_retrieve_expired_access_token(hass): """Test that we cannot retrieve expired access tokens.""" manager = await auth.auth_manager_from_config(hass, []) @@ -244,15 +218,20 @@ async def test_cannot_retrieve_expired_access_token(hass): assert refresh_token.client_id == CLIENT_ID access_token = manager.async_create_access_token(refresh_token) - assert manager.async_get_access_token(access_token.token) is access_token + assert ( + await manager.async_validate_access_token(access_token) + is refresh_token + ) with patch('homeassistant.util.dt.utcnow', - return_value=dt_util.utcnow() + - auth_const.ACCESS_TOKEN_EXPIRATION): - assert manager.async_get_access_token(access_token.token) is None + return_value=dt_util.utcnow() - + auth_const.ACCESS_TOKEN_EXPIRATION - timedelta(seconds=11)): + access_token = manager.async_create_access_token(refresh_token) - # Even with unpatched time, it should have been removed from manager - assert manager.async_get_access_token(access_token.token) is None + assert ( + await manager.async_validate_access_token(access_token) + is None + ) async def test_generating_system_user(hass): diff --git a/tests/common.py b/tests/common.py index 5567a431e58..81e4774ccd4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -12,7 +12,7 @@ import logging import threading from contextlib import contextmanager -from homeassistant import auth, core as ha, data_entry_flow, config_entries +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.setup import setup_component, async_setup_component @@ -266,7 +266,7 @@ def mock_state_change_event(hass, new_state, old_state=None): if old_state: event_data['old_state'] = old_state - hass.bus.fire(EVENT_STATE_CHANGED, event_data) + hass.bus.fire(EVENT_STATE_CHANGED, event_data, context=new_state.context) @asyncio.coroutine @@ -314,12 +314,18 @@ def mock_registry(hass, mock_entries=None): class MockUser(auth_models.User): """Mock a user in Home Assistant.""" - def __init__(self, id='mock-id', is_owner=False, is_active=True, + def __init__(self, id=None, is_owner=False, is_active=True, name='Mock User', system_generated=False): """Initialize mock user.""" - super().__init__( - id=id, is_owner=is_owner, is_active=is_active, name=name, - system_generated=system_generated) + kwargs = { + 'is_owner': is_owner, + 'is_active': is_active, + 'name': name, + 'system_generated': system_generated + } + if id is not None: + kwargs['id'] = id + super().__init__(**kwargs) def add_to_hass(self, hass): """Test helper to add entry to hass.""" @@ -509,7 +515,7 @@ class MockConfigEntry(config_entries.ConfigEntry): """Helper for creating config entries that adds some defaults.""" def __init__(self, *, domain='test', data=None, version=0, entry_id=None, - source=data_entry_flow.SOURCE_USER, title='Mock Title', + source=config_entries.SOURCE_USER, title='Mock Title', state=None): """Initialize a mock config entry.""" kwargs = { diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py index 7bd720ddf70..75e61af2e71 100644 --- a/tests/components/auth/test_indieauth.py +++ b/tests/components/auth/test_indieauth.py @@ -1,8 +1,12 @@ """Tests for the client validator.""" -from homeassistant.components.auth import indieauth +from unittest.mock import patch import pytest +from homeassistant.components.auth import indieauth + +from tests.common import mock_coro + def test_client_id_scheme(): """Test we enforce valid scheme.""" @@ -84,27 +88,65 @@ def test_parse_url_path(): assert indieauth._parse_url('http://ex.com').path == '/' -def test_verify_redirect_uri(): +async def test_verify_redirect_uri(): """Test that we verify redirect uri correctly.""" - assert indieauth.verify_redirect_uri( + assert await indieauth.verify_redirect_uri( + None, 'http://ex.com', 'http://ex.com/callback' ) - # Different domain - assert not indieauth.verify_redirect_uri( - 'http://ex.com', - 'http://different.com/callback' - ) + with patch.object(indieauth, 'fetch_redirect_uris', + side_effect=lambda *_: mock_coro([])): + # Different domain + assert not await indieauth.verify_redirect_uri( + None, + 'http://ex.com', + 'http://different.com/callback' + ) - # Different scheme - assert not indieauth.verify_redirect_uri( - 'http://ex.com', - 'https://ex.com/callback' - ) + # Different scheme + assert not await indieauth.verify_redirect_uri( + None, + 'http://ex.com', + 'https://ex.com/callback' + ) - # Different subdomain - assert not indieauth.verify_redirect_uri( - 'https://sub1.ex.com', - 'https://sub2.ex.com/callback' - ) + # Different subdomain + assert not await indieauth.verify_redirect_uri( + None, + 'https://sub1.ex.com', + 'https://sub2.ex.com/callback' + ) + + +async def test_find_link_tag(hass, aioclient_mock): + """Test finding link tag.""" + aioclient_mock.get("http://127.0.0.1:8000", text=""" + + + + + + + + ... + +""") + redirect_uris = await indieauth.fetch_redirect_uris( + hass, "http://127.0.0.1:8000") + + assert redirect_uris == [ + "hass://oauth2_redirect", + "http://127.0.0.1:8000/beer", + ] + + +async def test_find_link_tag_max_size(hass, aioclient_mock): + """Test finding link tag.""" + text = ("0" * 1024 * 10) + '' + aioclient_mock.get("http://127.0.0.1:8000", text=text) + redirect_uris = await indieauth.fetch_redirect_uris( + hass, "http://127.0.0.1:8000") + + assert redirect_uris == [] diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index eea768c96a0..f1a1bb5bd3c 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -44,7 +44,10 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): assert resp.status == 200 tokens = await resp.json() - assert hass.auth.async_get_access_token(tokens['access_token']) is not None + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) # Use refresh token to get more tokens. resp = await client.post('/auth/token', data={ @@ -56,7 +59,10 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): assert resp.status == 200 tokens = await resp.json() assert 'refresh_token' not in tokens - assert hass.auth.async_get_access_token(tokens['access_token']) is not None + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) # Test using access token to hit API. resp = await client.get('/api/') @@ -98,7 +104,9 @@ async def test_ws_current_user(hass, hass_ws_client, hass_access_token): } }) - user = hass_access_token.refresh_token.user + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + user = refresh_token.user credential = Credentials(auth_provider_type='homeassistant', auth_provider_id=None, data={}, id='test-id') @@ -169,7 +177,10 @@ async def test_refresh_token_system_generated(hass, aiohttp_client): assert resp.status == 200 tokens = await resp.json() - assert hass.auth.async_get_access_token(tokens['access_token']) is not None + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) async def test_refresh_token_different_client_id(hass, aiohttp_client): @@ -208,4 +219,7 @@ async def test_refresh_token_different_client_id(hass, aiohttp_client): assert resp.status == 200 tokens = await resp.json() - assert hass.auth.async_get_access_token(tokens['access_token']) is not None + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py index 13515db87fa..e209e0ee856 100644 --- a/tests/components/auth/test_init_link_user.py +++ b/tests/components/auth/test_init_link_user.py @@ -52,7 +52,7 @@ async def async_get_code(hass, aiohttp_client): 'user': user, 'code': step['result'], 'client': client, - 'access_token': access_token.token, + 'access_token': access_token, } diff --git a/tests/components/cast/test_init.py b/tests/components/cast/test_init.py index 3ed9ea7b88e..1ffbd375b75 100644 --- a/tests/components/cast/test_init.py +++ b/tests/components/cast/test_init.py @@ -1,7 +1,7 @@ """Tests for the Cast config flow.""" from unittest.mock import patch -from homeassistant import data_entry_flow +from homeassistant import config_entries, data_entry_flow from homeassistant.setup import async_setup_component from homeassistant.components import cast @@ -15,7 +15,8 @@ async def test_creating_entry_sets_up_media_player(hass): MockDependency('pychromecast', 'discovery'), \ patch('pychromecast.discovery.discover_chromecasts', return_value=True): - result = await hass.config_entries.flow.async_init(cast.DOMAIN) + result = await hass.config_entries.flow.async_init( + cast.DOMAIN, context={'source': config_entries.SOURCE_USER}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY await hass.async_block_till_done() diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 91f8ab8316d..014cdb1c6c6 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -73,8 +73,7 @@ def test_constructor_loads_info_from_config(): assert cl.relayer == 'test-relayer' -@asyncio.coroutine -def test_initialize_loads_info(mock_os, hass): +async def test_initialize_loads_info(mock_os, hass): """Test initialize will load info from config file.""" mock_os.path.isfile.return_value = True mopen = mock_open(read_data=json.dumps({ @@ -88,8 +87,10 @@ def test_initialize_loads_info(mock_os, hass): cl.iot.connect.return_value = mock_coro() with patch('homeassistant.components.cloud.open', mopen, create=True), \ + patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset', + return_value=mock_coro(True)), \ patch('homeassistant.components.cloud.Cloud._decode_claims'): - yield from cl.async_start(None) + await cl.async_start(None) assert cl.id_token == 'test-id-token' assert cl.access_token == 'test-access-token' diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index fe8f351955f..cd04eedf08e 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -122,11 +122,13 @@ async def test_delete_unable_self_account(hass, hass_ws_client, hass_access_token): """Test we cannot delete our own account.""" client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) await client.send_json({ 'id': 5, 'type': auth_config.WS_TYPE_DELETE, - 'user_id': hass_access_token.refresh_token.user.id, + 'user_id': refresh_token.user.id, }) result = await client.receive_json() @@ -137,7 +139,9 @@ async def test_delete_unable_self_account(hass, hass_ws_client, async def test_delete_unknown_user(hass, hass_ws_client, hass_access_token): """Test we cannot delete an unknown user.""" client = await hass_ws_client(hass, hass_access_token) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True await client.send_json({ 'id': 5, @@ -153,7 +157,9 @@ async def test_delete_unknown_user(hass, hass_ws_client, hass_access_token): async def test_delete(hass, hass_ws_client, hass_access_token): """Test delete command works.""" client = await hass_ws_client(hass, hass_access_token) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True test_user = MockUser( id='efg', ).add_to_hass(hass) @@ -174,7 +180,9 @@ async def test_delete(hass, hass_ws_client, hass_access_token): async def test_create(hass, hass_ws_client, hass_access_token): """Test create command works.""" client = await hass_ws_client(hass, hass_access_token) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True assert len(await hass.auth.async_get_users()) == 1 diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py index cd2cbc44539..a374083c2ab 100644 --- a/tests/components/config/test_auth_provider_homeassistant.py +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -9,7 +9,7 @@ from tests.common import MockUser, register_auth_provider @pytest.fixture(autouse=True) -def setup_config(hass, aiohttp_client): +def setup_config(hass): """Fixture that sets up the auth provider homeassistant module.""" hass.loop.run_until_complete(register_auth_provider(hass, { 'type': 'homeassistant' @@ -22,7 +22,9 @@ async def test_create_auth_system_generated_user(hass, hass_access_token, """Test we can't add auth to system generated users.""" system_user = MockUser(system_generated=True).add_to_hass(hass) client = await hass_ws_client(hass, hass_access_token) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True await client.send_json({ 'id': 5, @@ -47,7 +49,9 @@ async def test_create_auth_unknown_user(hass_ws_client, hass, hass_access_token): """Test create pointing at unknown user.""" client = await hass_ws_client(hass, hass_access_token) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True await client.send_json({ 'id': 5, @@ -86,7 +90,9 @@ async def test_create_auth(hass, hass_ws_client, hass_access_token, """Test create auth command works.""" client = await hass_ws_client(hass, hass_access_token) user = MockUser().add_to_hass(hass) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True assert len(user.credentials) == 0 @@ -117,7 +123,9 @@ async def test_create_auth_duplicate_username(hass, hass_ws_client, """Test we can't create auth with a duplicate username.""" client = await hass_ws_client(hass, hass_access_token) user = MockUser().add_to_hass(hass) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True hass_storage[prov_ha.STORAGE_KEY] = { 'version': 1, @@ -145,7 +153,9 @@ async def test_delete_removes_just_auth(hass_ws_client, hass, hass_storage, hass_access_token): """Test deleting an auth without being connected to a user.""" client = await hass_ws_client(hass, hass_access_token) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True hass_storage[prov_ha.STORAGE_KEY] = { 'version': 1, @@ -171,7 +181,9 @@ async def test_delete_removes_credential(hass, hass_ws_client, hass_access_token, hass_storage): """Test deleting auth that is connected to a user.""" client = await hass_ws_client(hass, hass_access_token) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True user = MockUser().add_to_hass(hass) user.credentials.append( @@ -216,7 +228,9 @@ async def test_delete_requires_owner(hass, hass_ws_client, hass_access_token): async def test_delete_unknown_auth(hass, hass_ws_client, hass_access_token): """Test trying to delete an unknown auth username.""" client = await hass_ws_client(hass, hass_access_token) - hass_access_token.refresh_token.user.is_owner = True + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True await client.send_json({ 'id': 5, @@ -240,7 +254,9 @@ async def test_change_password(hass, hass_ws_client, hass_access_token): 'username': 'test-user' }) - user = hass_access_token.refresh_token.user + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + user = refresh_token.user await hass.auth.async_link_user(user, credentials) client = await hass_ws_client(hass, hass_access_token) @@ -268,7 +284,9 @@ async def test_change_password_wrong_pw(hass, hass_ws_client, 'username': 'test-user' }) - user = hass_access_token.refresh_token.user + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + user = refresh_token.user await hass.auth.async_link_user(user, credentials) client = await hass_ws_client(hass, hass_access_token) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 82c747da01c..ba053050f99 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -102,13 +102,13 @@ def test_initialize_flow(hass, client): """Test we can initialize a flow.""" class TestFlow(FlowHandler): @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): schema = OrderedDict() schema[vol.Required('username')] = str schema[vol.Required('password')] = str return self.async_show_form( - step_id='init', + step_id='user', data_schema=schema, description_placeholders={ 'url': 'https://example.com', @@ -130,7 +130,7 @@ def test_initialize_flow(hass, client): assert data == { 'type': 'form', 'handler': 'test', - 'step_id': 'init', + 'step_id': 'user', 'data_schema': [ { 'name': 'username', @@ -157,7 +157,7 @@ def test_abort(hass, client): """Test a flow that aborts.""" class TestFlow(FlowHandler): @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_abort(reason='bla') with patch.dict(HANDLERS, {'test': TestFlow}): @@ -185,7 +185,7 @@ def test_create_account(hass, client): VERSION = 1 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_create_entry( title='Test Entry', data={'secret': 'account_token'} @@ -202,7 +202,6 @@ def test_create_account(hass, client): 'handler': 'test', 'title': 'Test Entry', 'type': 'create_entry', - 'source': 'user', 'version': 1, } @@ -218,7 +217,7 @@ def test_two_step_flow(hass, client): VERSION = 1 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_show_form( step_id='account', data_schema=vol.Schema({ @@ -264,7 +263,6 @@ def test_two_step_flow(hass, client): 'type': 'create_entry', 'title': 'user-title', 'version': 1, - 'source': 'user', } @@ -286,7 +284,7 @@ def test_get_progress_index(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): form = yield from hass.config_entries.flow.async_init( - 'test', source='hassio') + 'test', context={'source': 'hassio'}) resp = yield from client.get('/api/config/config_entries/flow') assert resp.status == 200 @@ -295,7 +293,7 @@ def test_get_progress_index(hass, client): { 'flow_id': form['flow_id'], 'handler': 'test', - 'source': 'hassio' + 'context': {'source': 'hassio'} } ] @@ -305,13 +303,13 @@ def test_get_progress_flow(hass, client): """Test we can query the API for same result as we get from init a flow.""" class TestFlow(FlowHandler): @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): schema = OrderedDict() schema[vol.Required('username')] = str schema[vol.Required('password')] = str return self.async_show_form( - step_id='init', + step_id='user', data_schema=schema, errors={ 'username': 'Should be unique.' diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 5f6a17a4101..bb9b643296e 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -28,7 +28,7 @@ def hass_ws_client(aiohttp_client): await websocket.send_json({ 'type': websocket_api.TYPE_AUTH, - 'access_token': access_token.token + 'access_token': access_token }) auth_ok = await websocket.receive_json() diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 8f5342de1e3..c6fc130a4a4 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -99,8 +99,8 @@ async def test_setup_entry_successful(hass): assert hass.data[deconz.DOMAIN] assert hass.data[deconz.DATA_DECONZ_ID] == {} assert len(hass.data[deconz.DATA_DECONZ_UNSUB]) == 1 - assert len(mock_add_job.mock_calls) == 4 - assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == 4 + assert len(mock_add_job.mock_calls) == 5 + assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == 5 assert mock_config_entries.async_forward_entry_setup.mock_calls[0][1] == \ (entry, 'binary_sensor') assert mock_config_entries.async_forward_entry_setup.mock_calls[1][1] == \ @@ -109,6 +109,8 @@ async def test_setup_entry_successful(hass): (entry, 'scene') assert mock_config_entries.async_forward_entry_setup.mock_calls[3][1] == \ (entry, 'sensor') + assert mock_config_entries.async_forward_entry_setup.mock_calls[4][1] == \ + (entry, 'switch') async def test_unload_entry(hass): diff --git a/tests/components/device_tracker/test_bt_home_hub_5.py b/tests/components/device_tracker/test_bt_home_hub_5.py deleted file mode 100644 index fd9692ec2b4..00000000000 --- a/tests/components/device_tracker/test_bt_home_hub_5.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The tests for the BT Home Hub 5 device tracker platform.""" -import unittest -from unittest.mock import patch - -from homeassistant.components.device_tracker import bt_home_hub_5 -from homeassistant.const import CONF_HOST - -patch_file = 'homeassistant.components.device_tracker.bt_home_hub_5' - - -def _get_homehub_data(url): - """Return mock homehub data.""" - return ''' - [ - { - "mac": "AA:BB:CC:DD:EE:FF, - "hostname": "hostname", - "ip": "192.168.1.43", - "ipv6": "", - "name": "hostname", - "activity": "1", - "os": "Unknown", - "device": "Unknown", - "time_first_seen": "2016/06/05 11:14:45", - "time_last_active": "2016/06/06 11:33:08", - "dhcp_option": "39043T90430T9TGK0EKGE5KGE3K904390K45GK054", - "port": "wl0", - "ipv6_ll": "fe80::gd67:ghrr:fuud:4332", - "activity_ip": "1", - "activity_ipv6_ll": "0", - "activity_ipv6": "0", - "device_oui": "NA", - "device_serial": "NA", - "device_class": "NA" - } - ] - ''' - - -class TestBTHomeHub5DeviceTracker(unittest.TestCase): - """Test BT Home Hub 5 device tracker platform.""" - - @patch('{}._get_homehub_data'.format(patch_file), new=_get_homehub_data) - def test_config_minimal(self): - """Test the setup with minimal configuration.""" - config = { - 'device_tracker': { - CONF_HOST: 'foo' - } - } - result = bt_home_hub_5.get_scanner(None, config) - - self.assertIsNotNone(result) diff --git a/tests/components/fan/test_dyson.py b/tests/components/fan/test_dyson.py index 49338e123e3..2953ea2754b 100644 --- a/tests/components/fan/test_dyson.py +++ b/tests/components/fan/test_dyson.py @@ -2,8 +2,11 @@ import unittest from unittest import mock +from homeassistant.setup import setup_component +from homeassistant.components import dyson as dyson_parent from homeassistant.components.dyson import DYSON_DEVICES -from homeassistant.components.fan import dyson +from homeassistant.components.fan import (dyson, ATTR_SPEED, ATTR_SPEED_LIST, + ATTR_OSCILLATING) from tests.common import get_test_home_assistant from libpurecoollink.const import FanSpeed, FanMode, NightMode, Oscillation from libpurecoollink.dyson_pure_state import DysonPureCoolState @@ -91,6 +94,45 @@ class DysonTest(unittest.TestCase): self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan] dyson.setup_platform(self.hass, None, _add_device) + @mock.patch('libpurecoollink.dyson.DysonAccount.devices', + return_value=[_get_device_on()]) + @mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True) + def test_get_state_attributes(self, mocked_login, mocked_devices): + """Test async added to hass.""" + setup_component(self.hass, dyson_parent.DOMAIN, { + dyson_parent.DOMAIN: { + dyson_parent.CONF_USERNAME: "email", + dyson_parent.CONF_PASSWORD: "password", + dyson_parent.CONF_LANGUAGE: "US", + } + }) + self.hass.block_till_done() + state = self.hass.states.get("{}.{}".format( + dyson.DOMAIN, + mocked_devices.return_value[0].name)) + + assert dyson.ATTR_IS_NIGHT_MODE in state.attributes + assert dyson.ATTR_IS_AUTO_MODE in state.attributes + assert ATTR_SPEED in state.attributes + assert ATTR_SPEED_LIST in state.attributes + assert ATTR_OSCILLATING in state.attributes + + @mock.patch('libpurecoollink.dyson.DysonAccount.devices', + return_value=[_get_device_on()]) + @mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True) + def test_async_added_to_hass(self, mocked_login, mocked_devices): + """Test async added to hass.""" + setup_component(self.hass, dyson_parent.DOMAIN, { + dyson_parent.DOMAIN: { + dyson_parent.CONF_USERNAME: "email", + dyson_parent.CONF_PASSWORD: "password", + dyson_parent.CONF_LANGUAGE: "US", + } + }) + self.hass.block_till_done() + self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) + assert mocked_devices.return_value[0].add_message_listener.called + def test_dyson_set_speed(self): """Test set fan speed.""" device = _get_device_on() diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index dfa67f48614..17bf3d953ef 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -348,3 +348,14 @@ async def test_onboarding_load(mock_http_client): """Test onboarding component loaded by default.""" resp = await mock_http_client.get('/api/onboarding') assert resp.status == 200 + + +async def test_auth_authorize(mock_http_client): + """Test the authorize endpoint works.""" + resp = await mock_http_client.get('/auth/authorize?hello=world') + assert resp.url.query_string == 'hello=world' + assert resp.url.path == '/frontend_es5/authorize.html' + + resp = await mock_http_client.get('/auth/authorize?latest&hello=world') + assert resp.url.query_string == 'latest&hello=world' + assert resp.url.path == '/frontend_latest/authorize.html' diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index b1975669731..4fd59dd3f7a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -106,7 +106,11 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, ) assert hassio_user is not None assert hassio_user.system_generated - assert refresh_token in hassio_user.refresh_tokens + for token in hassio_user.refresh_tokens.values(): + if token.token == refresh_token: + break + else: + assert False, 'refresh token not found' async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index f7839265939..55e02de7526 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -13,4 +13,4 @@ def hk_driver(): patch('pyhap.accessory_driver.AccessoryEncoder'), \ patch('pyhap.accessory_driver.HAPServer'), \ patch('pyhap.accessory_driver.AccessoryDriver.publish'): - return AccessoryDriver(pincode=b'123-45-678') + return AccessoryDriver(pincode=b'123-45-678', address='127.0.0.1') diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 23706f02e75..edb1c7175f8 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -104,6 +104,7 @@ async def test_battery_service(hass, hk_driver): await hass.async_block_till_done() acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2, None) + acc.update_state = lambda x: None assert acc._char_battery.value == 0 assert acc._char_low_battery.value == 0 assert acc._char_charging.value == 2 @@ -126,6 +127,7 @@ async def test_battery_service(hass, hk_driver): await hass.async_block_till_done() acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2, None) + acc.update_state = lambda x: None assert acc._char_battery.value == 0 assert acc._char_low_battery.value == 0 assert acc._char_charging.value == 2 diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 31cba79a6c8..8e7a62e2e9f 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -156,9 +156,9 @@ async def test_access_with_trusted_ip(app2, aiohttp_client): async def test_auth_active_access_with_access_token_in_header( - app, aiohttp_client, hass_access_token): + hass, app, aiohttp_client, hass_access_token): """Test access with access token in header.""" - token = hass_access_token.token + token = hass_access_token setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) @@ -182,7 +182,9 @@ async def test_auth_active_access_with_access_token_in_header( '/', headers={'Authorization': 'BEARER {}'.format(token)}) assert req.status == 401 - hass_access_token.refresh_token.user.is_active = False + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_active = False req = await client.get( '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 401 diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index d5368032a37..9f6441c5238 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,9 +1,13 @@ """The tests for the Home Assistant HTTP component.""" import logging +import unittest +from unittest.mock import patch from homeassistant.setup import async_setup_component import homeassistant.components.http as http +from homeassistant.util.ssl import ( + server_context_modern, server_context_intermediate) class TestView(http.HomeAssistantView): @@ -33,6 +37,50 @@ async def test_registering_view_while_running(hass, aiohttp_client, hass.http.register_view(TestView) +class TestApiConfig(unittest.TestCase): + """Test API configuration methods.""" + + def test_api_base_url_with_domain(hass): + """Test setting API URL with domain.""" + api_config = http.ApiConfig('example.com') + assert api_config.base_url == 'http://example.com:8123' + + def test_api_base_url_with_ip(hass): + """Test setting API URL with IP.""" + api_config = http.ApiConfig('1.1.1.1') + assert api_config.base_url == 'http://1.1.1.1:8123' + + def test_api_base_url_with_ip_and_port(hass): + """Test setting API URL with IP and port.""" + api_config = http.ApiConfig('1.1.1.1', 8124) + assert api_config.base_url == 'http://1.1.1.1:8124' + + def test_api_base_url_with_protocol(hass): + """Test setting API URL with protocol.""" + api_config = http.ApiConfig('https://example.com') + assert api_config.base_url == 'https://example.com:8123' + + def test_api_base_url_with_protocol_and_port(hass): + """Test setting API URL with protocol and port.""" + api_config = http.ApiConfig('https://example.com', 433) + assert api_config.base_url == 'https://example.com:433' + + def test_api_base_url_with_ssl_enable(hass): + """Test setting API URL with use_ssl enabled.""" + api_config = http.ApiConfig('example.com', use_ssl=True) + assert api_config.base_url == 'https://example.com:8123' + + def test_api_base_url_with_ssl_enable_and_port(hass): + """Test setting API URL with use_ssl enabled and port.""" + api_config = http.ApiConfig('1.1.1.1', use_ssl=True, port=8888) + assert api_config.base_url == 'https://1.1.1.1:8888' + + def test_api_base_url_with_protocol_and_ssl_enable(hass): + """Test setting API URL with specific protocol and use_ssl enabled.""" + api_config = http.ApiConfig('http://example.com', use_ssl=True) + assert api_config.base_url == 'http://example.com:8123' + + async def test_api_base_url_with_domain(hass): """Test setting API URL.""" result = await async_setup_component(hass, 'http', { @@ -96,3 +144,84 @@ async def test_not_log_password(hass, aiohttp_client, caplog): # Ensure we don't log API passwords assert '/api/' in logs assert 'some-pass' not in logs + + +async def test_proxy_config(hass): + """Test use_x_forwarded_for must config together with trusted_proxies.""" + assert await async_setup_component(hass, 'http', { + 'http': { + http.CONF_USE_X_FORWARDED_FOR: True, + http.CONF_TRUSTED_PROXIES: ['127.0.0.1'] + } + }) is True + + +async def test_proxy_config_only_use_xff(hass): + """Test use_x_forwarded_for must config together with trusted_proxies.""" + assert await async_setup_component(hass, 'http', { + 'http': { + http.CONF_USE_X_FORWARDED_FOR: True + } + }) is not True + + +async def test_proxy_config_only_trust_proxies(hass): + """Test use_x_forwarded_for must config together with trusted_proxies.""" + assert await async_setup_component(hass, 'http', { + 'http': { + http.CONF_TRUSTED_PROXIES: ['127.0.0.1'] + } + }) is not True + + +async def test_ssl_profile_defaults_modern(hass): + """Test default ssl profile.""" + assert await async_setup_component(hass, 'http', {}) is True + + hass.http.ssl_certificate = 'bla' + + with patch('ssl.SSLContext.load_cert_chain'), \ + patch('homeassistant.util.ssl.server_context_modern', + side_effect=server_context_modern) as mock_context: + await hass.async_start() + await hass.async_block_till_done() + + assert len(mock_context.mock_calls) == 1 + + +async def test_ssl_profile_change_intermediate(hass): + """Test setting ssl profile to intermediate.""" + assert await async_setup_component(hass, 'http', { + 'http': { + 'ssl_profile': 'intermediate' + } + }) is True + + hass.http.ssl_certificate = 'bla' + + with patch('ssl.SSLContext.load_cert_chain'), \ + patch('homeassistant.util.ssl.server_context_intermediate', + side_effect=server_context_intermediate) as mock_context: + await hass.async_start() + await hass.async_block_till_done() + + assert len(mock_context.mock_calls) == 1 + + +async def test_ssl_profile_change_modern(hass): + """Test setting ssl profile to modern.""" + assert await async_setup_component(hass, 'http', { + 'http': { + 'ssl_profile': 'modern' + } + }) is True + + hass.http.ssl_certificate = 'bla' + + with patch('ssl.SSLContext.load_cert_chain'), \ + patch('homeassistant.util.ssl.server_context_modern', + side_effect=server_context_modern) as mock_context: + await hass.async_start() + await hass.async_block_till_done() + + assert len(mock_context.mock_calls) == 1 diff --git a/tests/components/image_processing/test_facebox.py b/tests/components/image_processing/test_facebox.py index 86811f94db3..b1d9fb8bf79 100644 --- a/tests/components/image_processing/test_facebox.py +++ b/tests/components/image_processing/test_facebox.py @@ -7,19 +7,19 @@ import requests_mock from homeassistant.core import callback from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_NAME, CONF_FRIENDLY_NAME, - CONF_IP_ADDRESS, CONF_PORT, STATE_UNKNOWN) + ATTR_ENTITY_ID, ATTR_NAME, CONF_FRIENDLY_NAME, CONF_PASSWORD, + CONF_USERNAME, CONF_IP_ADDRESS, CONF_PORT, + HTTP_BAD_REQUEST, HTTP_OK, HTTP_UNAUTHORIZED, STATE_UNKNOWN) from homeassistant.setup import async_setup_component import homeassistant.components.image_processing as ip import homeassistant.components.image_processing.facebox as fb -# pylint: disable=redefined-outer-name - MOCK_IP = '192.168.0.1' MOCK_PORT = '8080' # Mock data returned by the facebox API. -MOCK_ERROR = "No face found" +MOCK_BOX_ID = 'b893cc4f7fd6' +MOCK_ERROR_NO_FACE = "No face found" MOCK_FACE = {'confidence': 0.5812028911604818, 'id': 'john.jpg', 'matched': True, @@ -28,14 +28,21 @@ MOCK_FACE = {'confidence': 0.5812028911604818, MOCK_FILE_PATH = '/images/mock.jpg' +MOCK_HEALTH = {'success': True, + 'hostname': 'b893cc4f7fd6', + 'metadata': {'boxname': 'facebox', 'build': 'development'}, + 'errors': []} + MOCK_JSON = {"facesCount": 1, "success": True, "faces": [MOCK_FACE]} MOCK_NAME = 'mock_name' +MOCK_USERNAME = 'mock_username' +MOCK_PASSWORD = 'mock_password' # Faces data after parsing. -PARSED_FACES = [{ATTR_NAME: 'John Lennon', +PARSED_FACES = [{fb.FACEBOX_NAME: 'John Lennon', fb.ATTR_IMAGE_ID: 'john.jpg', fb.ATTR_CONFIDENCE: 58.12, fb.ATTR_MATCHED: True, @@ -62,6 +69,15 @@ VALID_CONFIG = { } +@pytest.fixture +def mock_healthybox(): + """Mock fb.check_box_health.""" + check_box_health = 'homeassistant.components.image_processing.' \ + 'facebox.check_box_health' + with patch(check_box_health, return_value=MOCK_BOX_ID) as _mock_healthybox: + yield _mock_healthybox + + @pytest.fixture def mock_isfile(): """Mock os.path.isfile.""" @@ -70,6 +86,14 @@ def mock_isfile(): yield _mock_isfile +@pytest.fixture +def mock_image(): + """Return a mock camera image.""" + with patch('homeassistant.components.camera.demo.DemoCamera.camera_image', + return_value=b'Test') as image: + yield image + + @pytest.fixture def mock_open_file(): """Mock open.""" @@ -79,6 +103,22 @@ def mock_open_file(): yield _mock_open +def test_check_box_health(caplog): + """Test check box health.""" + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/healthz".format(MOCK_IP, MOCK_PORT) + mock_req.get(url, status_code=HTTP_OK, json=MOCK_HEALTH) + assert fb.check_box_health(url, 'user', 'pass') == MOCK_BOX_ID + + mock_req.get(url, status_code=HTTP_UNAUTHORIZED) + assert fb.check_box_health(url, None, None) is None + assert "AuthenticationError on facebox" in caplog.text + + mock_req.get(url, exc=requests.exceptions.ConnectTimeout) + fb.check_box_health(url, None, None) + assert "ConnectionError: Is facebox running?" in caplog.text + + def test_encode_image(): """Test that binary data is encoded correctly.""" assert fb.encode_image(b'test') == 'dGVzdA==' @@ -100,22 +140,24 @@ def test_valid_file_path(): assert not fb.valid_file_path('test_path') -@pytest.fixture -def mock_image(): - """Return a mock camera image.""" - with patch('homeassistant.components.camera.demo.DemoCamera.camera_image', - return_value=b'Test') as image: - yield image - - -async def test_setup_platform(hass): +async def test_setup_platform(hass, mock_healthybox): """Setup platform with one entity.""" await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) assert hass.states.get(VALID_ENTITY_ID) -async def test_process_image(hass, mock_image): - """Test processing of an image.""" +async def test_setup_platform_with_auth(hass, mock_healthybox): + """Setup platform with one entity and auth.""" + valid_config_auth = VALID_CONFIG.copy() + valid_config_auth[ip.DOMAIN][CONF_USERNAME] = MOCK_USERNAME + valid_config_auth[ip.DOMAIN][CONF_PASSWORD] = MOCK_PASSWORD + + await async_setup_component(hass, ip.DOMAIN, valid_config_auth) + assert hass.states.get(VALID_ENTITY_ID) + + +async def test_process_image(hass, mock_healthybox, mock_image): + """Test successful processing of an image.""" await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) assert hass.states.get(VALID_ENTITY_ID) @@ -157,11 +199,12 @@ async def test_process_image(hass, mock_image): PARSED_FACES[0][fb.ATTR_BOUNDING_BOX]) -async def test_connection_error(hass, mock_image): - """Test connection error.""" +async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog): + """Test process_image errors.""" await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) assert hass.states.get(VALID_ENTITY_ID) + # Test connection error. with requests_mock.Mocker() as mock_req: url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) mock_req.register_uri( @@ -171,34 +214,40 @@ async def test_connection_error(hass, mock_image): ip.SERVICE_SCAN, service_data=data) await hass.async_block_till_done() + assert "ConnectionError: Is facebox running?" in caplog.text state = hass.states.get(VALID_ENTITY_ID) assert state.state == STATE_UNKNOWN assert state.attributes.get('faces') == [] assert state.attributes.get('matched_faces') == {} + # Now test with bad auth. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + mock_req.register_uri( + 'POST', url, status_code=HTTP_UNAUTHORIZED) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, + ip.SERVICE_SCAN, + service_data=data) + await hass.async_block_till_done() + assert "AuthenticationError on facebox" in caplog.text -async def test_teach_service(hass, mock_image, mock_isfile, mock_open_file): + +async def test_teach_service( + hass, mock_healthybox, mock_image, + mock_isfile, mock_open_file, caplog): """Test teaching of facebox.""" await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) assert hass.states.get(VALID_ENTITY_ID) - teach_events = [] - - @callback - def mock_teach_event(event): - """Mock event.""" - teach_events.append(event) - - hass.bus.async_listen( - 'image_processing.teach_classifier', mock_teach_event) - # Patch out 'is_allowed_path' as the mock files aren't allowed hass.config.is_allowed_path = Mock(return_value=True) + # Test successful teach. with requests_mock.Mocker() as mock_req: url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) - mock_req.post(url, status_code=200) + mock_req.post(url, status_code=HTTP_OK) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, ATTR_NAME: MOCK_NAME, fb.FILE_PATH: MOCK_FILE_PATH} @@ -206,17 +255,10 @@ async def test_teach_service(hass, mock_image, mock_isfile, mock_open_file): ip.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data) await hass.async_block_till_done() - assert len(teach_events) == 1 - assert teach_events[0].data[fb.ATTR_CLASSIFIER] == fb.CLASSIFIER - assert teach_events[0].data[ATTR_NAME] == MOCK_NAME - assert teach_events[0].data[fb.FILE_PATH] == MOCK_FILE_PATH - assert teach_events[0].data['success'] - assert not teach_events[0].data['message'] - - # Now test the failed teaching. + # Now test with bad auth. with requests_mock.Mocker() as mock_req: url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) - mock_req.post(url, status_code=400, text=MOCK_ERROR) + mock_req.post(url, status_code=HTTP_UNAUTHORIZED) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, ATTR_NAME: MOCK_NAME, fb.FILE_PATH: MOCK_FILE_PATH} @@ -224,16 +266,37 @@ async def test_teach_service(hass, mock_image, mock_isfile, mock_open_file): fb.SERVICE_TEACH_FACE, service_data=data) await hass.async_block_till_done() + assert "AuthenticationError on facebox" in caplog.text - assert len(teach_events) == 2 - assert teach_events[1].data[fb.ATTR_CLASSIFIER] == fb.CLASSIFIER - assert teach_events[1].data[ATTR_NAME] == MOCK_NAME - assert teach_events[1].data[fb.FILE_PATH] == MOCK_FILE_PATH - assert not teach_events[1].data['success'] - assert teach_events[1].data['message'] == MOCK_ERROR + # Now test the failed teaching. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, status_code=HTTP_BAD_REQUEST, + text=MOCK_ERROR_NO_FACE) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, + ATTR_NAME: MOCK_NAME, + fb.FILE_PATH: MOCK_FILE_PATH} + await hass.services.async_call(ip.DOMAIN, + fb.SERVICE_TEACH_FACE, + service_data=data) + await hass.async_block_till_done() + assert MOCK_ERROR_NO_FACE in caplog.text + + # Now test connection error. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, exc=requests.exceptions.ConnectTimeout) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, + ATTR_NAME: MOCK_NAME, + fb.FILE_PATH: MOCK_FILE_PATH} + await hass.services.async_call(ip.DOMAIN, + fb.SERVICE_TEACH_FACE, + service_data=data) + await hass.async_block_till_done() + assert "ConnectionError: Is facebox running?" in caplog.text -async def test_setup_platform_with_name(hass): +async def test_setup_platform_with_name(hass, mock_healthybox): """Setup platform with one entity and a name.""" named_entity_id = 'image_processing.{}'.format(MOCK_NAME) diff --git a/tests/components/light/test_deconz.py b/tests/components/light/test_deconz.py index d7d609f820e..df088d7a1b5 100644 --- a/tests/components/light/test_deconz.py +++ b/tests/components/light/test_deconz.py @@ -37,6 +37,15 @@ GROUP = { }, } +SWITCH = { + "1": { + "id": "Switch 1 id", + "name": "Switch 1 name", + "type": "On/Off plug-in unit", + "state": {} + } +} + async def setup_bridge(hass, data, allow_deconz_groups=True): """Load the deCONZ light platform.""" @@ -112,3 +121,10 @@ async def test_do_not_add_deconz_groups(hass): async_dispatcher_send(hass, 'deconz_new_group', [group]) await hass.async_block_till_done() assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + + +async def test_no_switch(hass): + """Test that a switch doesn't get created as a light entity.""" + await setup_bridge(hass, {"lights": SWITCH}) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 1c37c9049f3..d5d54f457d6 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -4,6 +4,7 @@ import sys import pytest +from homeassistant.const import CONF_PASSWORD from homeassistant.setup import setup_component import homeassistant.components.mqtt as mqtt @@ -19,9 +20,6 @@ class TestMQTT: def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - setup_component(self.hass, 'http', { - 'api_password': 'super_secret' - }) def teardown_method(self, method): """Stop everything that was started.""" @@ -32,14 +30,36 @@ class TestMQTT: @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) @patch('homeassistant.components.mqtt.MQTT') - def test_creating_config_with_http_pass(self, mock_mqtt): - """Test if the MQTT server gets started and subscribe/publish msg.""" + def test_creating_config_with_http_pass_only(self, mock_mqtt): + """Test if the MQTT server failed starts. + + Since 0.77, MQTT server has to setup its own password. + If user has api_password but don't have mqtt.password, MQTT component + will fail to start + """ mock_mqtt().async_connect.return_value = mock_coro(True) self.hass.bus.listen_once = MagicMock() - password = 'super_secret' + assert not setup_component(self.hass, mqtt.DOMAIN, { + 'http': {'api_password': 'http_secret'} + }) - self.hass.config.api = MagicMock(api_password=password) - assert setup_component(self.hass, mqtt.DOMAIN, {}) + @patch('passlib.apps.custom_app_context', Mock(return_value='')) + @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) + @patch('homeassistant.components.mqtt.MQTT') + def test_creating_config_with_pass_and_no_http_pass(self, mock_mqtt): + """Test if the MQTT server gets started with password. + + Since 0.77, MQTT server has to setup its own password. + """ + mock_mqtt().async_connect.return_value = mock_coro(True) + self.hass.bus.listen_once = MagicMock() + password = 'mqtt_secret' + + assert setup_component(self.hass, mqtt.DOMAIN, { + mqtt.DOMAIN: {CONF_PASSWORD: password}, + }) assert mock_mqtt.called from pprint import pprint pprint(mock_mqtt.mock_calls) @@ -51,8 +71,33 @@ class TestMQTT: @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) @patch('homeassistant.components.mqtt.MQTT') - def test_creating_config_with_http_no_pass(self, mock_mqtt): - """Test if the MQTT server gets started and subscribe/publish msg.""" + def test_creating_config_with_pass_and_http_pass(self, mock_mqtt): + """Test if the MQTT server gets started with password. + + Since 0.77, MQTT server has to setup its own password. + """ + mock_mqtt().async_connect.return_value = mock_coro(True) + self.hass.bus.listen_once = MagicMock() + password = 'mqtt_secret' + + self.hass.config.api = MagicMock(api_password='api_password') + assert setup_component(self.hass, mqtt.DOMAIN, { + 'http': {'api_password': 'http_secret'}, + mqtt.DOMAIN: {CONF_PASSWORD: password}, + }) + assert mock_mqtt.called + from pprint import pprint + pprint(mock_mqtt.mock_calls) + assert mock_mqtt.mock_calls[1][1][5] == 'homeassistant' + assert mock_mqtt.mock_calls[1][1][6] == password + + @patch('passlib.apps.custom_app_context', Mock(return_value='')) + @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) + @patch('homeassistant.components.mqtt.MQTT') + def test_creating_config_without_pass(self, mock_mqtt): + """Test if the MQTT server gets started without password.""" mock_mqtt().async_connect.return_value = mock_coro(True) self.hass.bus.listen_once = MagicMock() diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index c616f3d0af1..3d1beb3a642 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -60,7 +60,7 @@ class TestStates(unittest.TestCase): 'entity_id': 'sensor.temperature', 'old_state': None, 'new_state': state, - }) + }, context=state.context) assert state == States.from_event(event).to_native() def test_from_event_to_delete_state(self): diff --git a/tests/components/sensor/test_rmvtransport.py b/tests/components/sensor/test_rmvtransport.py new file mode 100644 index 00000000000..9db19ecde49 --- /dev/null +++ b/tests/components/sensor/test_rmvtransport.py @@ -0,0 +1,173 @@ +"""The tests for the rmvtransport platform.""" +import unittest +from unittest.mock import patch +import datetime + +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant + +VALID_CONFIG_MINIMAL = {'sensor': {'platform': 'rmvtransport', + 'next_departure': [{'station': '3000010'}]}} + +VALID_CONFIG_NAME = {'sensor': { + 'platform': 'rmvtransport', + 'next_departure': [ + { + 'station': '3000010', + 'name': 'My Station', + } + ]}} + +VALID_CONFIG_MISC = {'sensor': { + 'platform': 'rmvtransport', + 'next_departure': [ + { + 'station': '3000010', + 'lines': [21, 'S8'], + 'max_journeys': 2, + 'time_offset': 10 + } + ]}} + +VALID_CONFIG_DEST = {'sensor': { + 'platform': 'rmvtransport', + 'next_departure': [ + { + 'station': '3000010', + 'destinations': ['Frankfurt (Main) Flughafen Regionalbahnhof', + 'Frankfurt (Main) Stadion'] + } + ]}} + + +def get_departuresMock(stationId, maxJourneys, + products): # pylint: disable=invalid-name + """Mock rmvtransport departures loading.""" + data = {'station': 'Frankfurt (Main) Hauptbahnhof', + 'stationId': '3000010', 'filter': '11111111111', 'journeys': [ + {'product': 'Tram', 'number': 12, 'trainId': '1123456', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 21), + 'minutes': 7, 'delay': 3, 'stops': [ + 'Frankfurt (Main) Willy-Brandt-Platz', + 'Frankfurt (Main) Römer/Paulskirche', + 'Frankfurt (Main) Börneplatz', + 'Frankfurt (Main) Konstablerwache', + 'Frankfurt (Main) Bornheim Mitte', + 'Frankfurt (Main) Saalburg-/Wittelsbacherallee', + 'Frankfurt (Main) Eissporthalle/Festplatz', + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 21, 'trainId': '1234567', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 22), + 'minutes': 8, 'delay': 1, 'stops': [ + 'Frankfurt (Main) Weser-/Münchener Straße', + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 12, 'trainId': '1234568', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 25), + 'minutes': 11, 'delay': 1, 'stops': [ + 'Frankfurt (Main) Stadion'], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 21, 'trainId': '1234569', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 25), + 'minutes': 11, 'delay': 1, 'stops': [], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 12, 'trainId': '1234570', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 25), + 'minutes': 11, 'delay': 1, 'stops': [], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 21, 'trainId': '1234571', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 25), + 'minutes': 11, 'delay': 1, 'stops': [], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'} + ]} + return data + + +def get_errDeparturesMock(stationId, maxJourneys, + products): # pylint: disable=invalid-name + """Mock rmvtransport departures erroneous loading.""" + raise ValueError + + +class TestRMVtransportSensor(unittest.TestCase): + """Test the rmvtransport sensor.""" + + def setUp(self): + """Set up things to run when tests begin.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG_MINIMAL + self.reference = {} + self.entities = [] + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_departuresMock) + def test_rmvtransport_min_config(self, mock_get_departures): + """Test minimal rmvtransport configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) + state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') + self.assertEqual(state.state, '7') + self.assertEqual(state.attributes['departure_time'], + datetime.datetime(2018, 8, 6, 14, 21)) + self.assertEqual(state.attributes['direction'], + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife') + self.assertEqual(state.attributes['product'], 'Tram') + self.assertEqual(state.attributes['line'], 12) + self.assertEqual(state.attributes['icon'], 'mdi:tram') + self.assertEqual(state.attributes['friendly_name'], + 'Frankfurt (Main) Hauptbahnhof') + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_departuresMock) + def test_rmvtransport_name_config(self, mock_get_departures): + """Test custom name configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_NAME) + state = self.hass.states.get('sensor.my_station') + self.assertEqual(state.attributes['friendly_name'], 'My Station') + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_errDeparturesMock) + def test_rmvtransport_err_config(self, mock_get_departures): + """Test erroneous rmvtransport configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_departuresMock) + def test_rmvtransport_misc_config(self, mock_get_departures): + """Test misc configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_MISC) + state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') + self.assertEqual(state.attributes['friendly_name'], + 'Frankfurt (Main) Hauptbahnhof') + self.assertEqual(state.attributes['line'], 21) + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_departuresMock) + def test_rmvtransport_dest_config(self, mock_get_departures): + """Test misc configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_DEST) + state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') + self.assertEqual(state.state, '11') + self.assertEqual(state.attributes['direction'], + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife') + self.assertEqual(state.attributes['line'], 12) + self.assertEqual(state.attributes['minutes'], 11) + self.assertEqual(state.attributes['departure_time'], + datetime.datetime(2018, 8, 6, 14, 25)) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 9fe22fc7e79..ab4eed31fee 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,7 +1,7 @@ """Tests for the Sonos config flow.""" from unittest.mock import patch -from homeassistant import data_entry_flow +from homeassistant import config_entries, data_entry_flow from homeassistant.setup import async_setup_component from homeassistant.components import sonos @@ -13,7 +13,8 @@ async def test_creating_entry_sets_up_media_player(hass): with patch('homeassistant.components.media_player.sonos.async_setup_entry', return_value=mock_coro(True)) as mock_setup, \ patch('soco.discover', return_value=True): - result = await hass.config_entries.flow.async_init(sonos.DOMAIN) + result = await hass.config_entries.flow.async_init( + sonos.DOMAIN, context={'source': config_entries.SOURCE_USER}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY await hass.async_block_till_done() diff --git a/tests/components/switch/test_deconz.py b/tests/components/switch/test_deconz.py new file mode 100644 index 00000000000..57fc8b3bcd9 --- /dev/null +++ b/tests/components/switch/test_deconz.py @@ -0,0 +1,97 @@ +"""deCONZ switch platform tests.""" +from unittest.mock import Mock, patch + +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.components.deconz.const import SWITCH_TYPES +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import mock_coro + +SUPPORTED_SWITCHES = { + "1": { + "id": "Switch 1 id", + "name": "Switch 1 name", + "type": "On/Off plug-in unit", + "state": {} + }, + "2": { + "id": "Switch 2 id", + "name": "Switch 2 name", + "type": "Smart plug", + "state": {} + }, + "3": { + "id": "Switch 3 id", + "name": "Switch 3 name", + "type": "Warning device", + "state": {} + } +} + +UNSUPPORTED_SWITCH = { + "1": { + "id": "Switch id", + "name": "Unsupported switch", + "type": "Not a smart plug", + "state": {} + } +} + + +async def setup_bridge(hass, data): + """Load the deCONZ switch platform.""" + from pydeconz import DeconzSession + loop = Mock() + session = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + bridge = DeconzSession(loop, session, **entry.data) + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(data)): + await bridge.async_load_parameters() + hass.data[deconz.DOMAIN] = bridge + hass.data[deconz.DATA_DECONZ_UNSUB] = [] + hass.data[deconz.DATA_DECONZ_ID] = {} + config_entry = config_entries.ConfigEntry( + 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + await hass.config_entries.async_forward_entry_setup(config_entry, 'switch') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_no_switches(hass): + """Test that no switch entities are created.""" + data = {} + await setup_bridge(hass, data) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 + + +async def test_switch(hass): + """Test that all supported switch entities and switch group are created.""" + await setup_bridge(hass, {"lights": SUPPORTED_SWITCHES}) + assert "switch.switch_1_name" in hass.data[deconz.DATA_DECONZ_ID] + assert "switch.switch_2_name" in hass.data[deconz.DATA_DECONZ_ID] + assert "switch.switch_3_name" in hass.data[deconz.DATA_DECONZ_ID] + assert len(SUPPORTED_SWITCHES) == len(SWITCH_TYPES) + assert len(hass.states.async_all()) == 4 + + +async def test_add_new_switch(hass): + """Test successful creation of switch entity.""" + data = {} + await setup_bridge(hass, data) + switch = Mock() + switch.name = 'name' + switch.type = "Smart plug" + switch.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_light', [switch]) + await hass.async_block_till_done() + assert "switch.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_unsupported_switch(hass): + """Test that unsupported switches are not created.""" + await setup_bridge(hass, {"lights": UNSUPPORTED_SWITCH}) + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 09dc27e97c1..2be1168b86a 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -448,13 +448,15 @@ async def test_api_fire_event_context(hass, mock_api_client, await mock_api_client.post( const.URL_API_EVENTS_EVENT.format("test.event"), headers={ - 'authorization': 'Bearer {}'.format(hass_access_token.token) + 'authorization': 'Bearer {}'.format(hass_access_token) }) await hass.async_block_till_done() + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + assert len(test_value) == 1 - assert test_value[0].context.user_id == \ - hass_access_token.refresh_token.user.id + assert test_value[0].context.user_id == refresh_token.user.id async def test_api_call_service_context(hass, mock_api_client, @@ -465,12 +467,15 @@ async def test_api_call_service_context(hass, mock_api_client, await mock_api_client.post( '/api/services/test_domain/test_service', headers={ - 'authorization': 'Bearer {}'.format(hass_access_token.token) + 'authorization': 'Bearer {}'.format(hass_access_token) }) await hass.async_block_till_done() + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + assert len(calls) == 1 - assert calls[0].context.user_id == hass_access_token.refresh_token.user.id + assert calls[0].context.user_id == refresh_token.user.id async def test_api_set_state_context(hass, mock_api_client, hass_access_token): @@ -481,8 +486,11 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token): 'state': 'on' }, headers={ - 'authorization': 'Bearer {}'.format(hass_access_token.token) + 'authorization': 'Bearer {}'.format(hass_access_token) }) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + state = hass.states.get('light.kitchen') - assert state.context.user_id == hass_access_token.refresh_token.user.id + assert state.context.user_id == refresh_token.user.id diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index dd22c87cb18..8b997cb911c 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock import pytest -from homeassistant import data_entry_flow +from homeassistant import config_entries from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery from homeassistant.util.dt import utcnow @@ -175,5 +175,5 @@ async def test_discover_config_flow(hass): assert len(m_init.mock_calls) == 1 args, kwargs = m_init.mock_calls[0][1:] assert args == ('mock-component',) - assert kwargs['source'] == data_entry_flow.SOURCE_DISCOVERY + assert kwargs['context']['source'] == config_entries.SOURCE_DISCOVERY assert kwargs['data'] == discovery_info diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 70f7152e07f..b348498b07e 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -83,9 +83,10 @@ class TestComponentHistory(unittest.TestCase): self.wait_recording_done() # Get states returns everything before POINT - self.assertEqual(states, - sorted(history.get_states(self.hass, future), - key=lambda state: state.entity_id)) + for state1, state2 in zip( + states, sorted(history.get_states(self.hass, future), + key=lambda state: state.entity_id)): + assert state1 == state2 # Test get_state here because we have a DB setup self.assertEqual( diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 1fac1af9f64..199a9d804f8 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -334,7 +334,7 @@ async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): await ws.send_json({ 'type': wapi.TYPE_AUTH, - 'access_token': hass_access_token.token + 'access_token': hass_access_token }) auth_msg = await ws.receive_json() @@ -344,7 +344,9 @@ async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): async def test_auth_active_user_inactive(hass, aiohttp_client, hass_access_token): """Test authenticating with a token.""" - hass_access_token.refresh_token.user.is_active = False + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_active = False assert await async_setup_component(hass, 'websocket_api', { 'http': { 'api_password': API_PASSWORD @@ -361,7 +363,7 @@ async def test_auth_active_user_inactive(hass, aiohttp_client, await ws.send_json({ 'type': wapi.TYPE_AUTH, - 'access_token': hass_access_token.token + 'access_token': hass_access_token }) auth_msg = await ws.receive_json() @@ -465,7 +467,7 @@ async def test_call_service_context_with_user(hass, aiohttp_client, await ws.send_json({ 'type': wapi.TYPE_AUTH, - 'access_token': hass_access_token.token + 'access_token': hass_access_token }) auth_msg = await ws.receive_json() @@ -484,12 +486,15 @@ async def test_call_service_context_with_user(hass, aiohttp_client, msg = await ws.receive_json() assert msg['success'] + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + assert len(calls) == 1 call = calls[0] assert call.domain == 'domain_test' assert call.service == 'test_service' assert call.data == {'hello': 'world'} - assert call.context.user_id == hass_access_token.refresh_token.user.id + assert call.context.user_id == refresh_token.user.id async def test_call_service_context_no_user(hass, aiohttp_client): diff --git a/tests/components/vacuum/test_demo.py b/tests/components/vacuum/test_demo.py index fadafdbc15e..bd6f2ae543c 100644 --- a/tests/components/vacuum/test_demo.py +++ b/tests/components/vacuum/test_demo.py @@ -6,10 +6,12 @@ from homeassistant.components.vacuum import ( ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, ATTR_PARAMS, ATTR_STATUS, DOMAIN, ENTITY_ID_ALL_VACUUMS, - SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED) + SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, + STATE_DOCKED, STATE_CLEANING, STATE_PAUSED, STATE_IDLE, + STATE_RETURNING) from homeassistant.components.vacuum.demo import ( DEMO_VACUUM_BASIC, DEMO_VACUUM_COMPLETE, DEMO_VACUUM_MINIMAL, - DEMO_VACUUM_MOST, DEMO_VACUUM_NONE, FAN_SPEEDS) + DEMO_VACUUM_MOST, DEMO_VACUUM_NONE, DEMO_VACUUM_STATE, FAN_SPEEDS) from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, STATE_OFF, STATE_ON) from homeassistant.setup import setup_component @@ -21,6 +23,7 @@ ENTITY_VACUUM_COMPLETE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_COMPLETE).lower() ENTITY_VACUUM_MINIMAL = '{}.{}'.format(DOMAIN, DEMO_VACUUM_MINIMAL).lower() ENTITY_VACUUM_MOST = '{}.{}'.format(DOMAIN, DEMO_VACUUM_MOST).lower() ENTITY_VACUUM_NONE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_NONE).lower() +ENTITY_VACUUM_STATE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_STATE).lower() class TestVacuumDemo(unittest.TestCase): @@ -79,6 +82,14 @@ class TestVacuumDemo(unittest.TestCase): self.assertEqual(None, state.attributes.get(ATTR_FAN_SPEED_LIST)) self.assertEqual(STATE_OFF, state.state) + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(13436, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertEqual(STATE_DOCKED, state.state) + self.assertEqual(100, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual("medium", state.attributes.get(ATTR_FAN_SPEED)) + self.assertListEqual(FAN_SPEEDS, + state.attributes.get(ATTR_FAN_SPEED_LIST)) + def test_methods(self): """Test if methods call the services as expected.""" self.hass.states.set(ENTITY_VACUUM_BASIC, STATE_ON) @@ -147,6 +158,41 @@ class TestVacuumDemo(unittest.TestCase): self.assertIn("spot", state.attributes.get(ATTR_STATUS)) self.assertEqual(STATE_ON, state.state) + vacuum.start(self.hass, ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_CLEANING, state.state) + + vacuum.pause(self.hass, ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_PAUSED, state.state) + + vacuum.stop(self.hass, ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_IDLE, state.state) + + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertLess(state.attributes.get(ATTR_BATTERY_LEVEL), 100) + self.assertNotEqual(STATE_DOCKED, state.state) + + vacuum.return_to_base(self.hass, ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_RETURNING, state.state) + + vacuum.set_fan_speed(self.hass, FAN_SPEEDS[-1], + entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(FAN_SPEEDS[-1], state.attributes.get(ATTR_FAN_SPEED)) + + vacuum.clean_spot(self.hass, entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_CLEANING, state.state) + def test_unsupported_methods(self): """Test service calls for unsupported vacuums.""" self.hass.states.set(ENTITY_VACUUM_NONE, STATE_ON) @@ -201,6 +247,39 @@ class TestVacuumDemo(unittest.TestCase): self.assertNotIn("spot", state.attributes.get(ATTR_STATUS)) self.assertEqual(STATE_OFF, state.state) + # VacuumDevice should not support start and pause methods. + self.hass.states.set(ENTITY_VACUUM_COMPLETE, STATE_ON) + self.hass.block_till_done() + self.assertTrue(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.pause(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertTrue(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + self.hass.states.set(ENTITY_VACUUM_COMPLETE, STATE_OFF) + self.hass.block_till_done() + self.assertFalse(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.start(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertFalse(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + # StateVacuumDevice does not support on/off + vacuum.turn_on(self.hass, entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertNotEqual(STATE_CLEANING, state.state) + + vacuum.turn_off(self.hass, entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertNotEqual(STATE_RETURNING, state.state) + + vacuum.toggle(self.hass, entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertNotEqual(STATE_CLEANING, state.state) + def test_services(self): """Test vacuum services.""" # Test send_command @@ -241,9 +320,11 @@ class TestVacuumDemo(unittest.TestCase): def test_set_fan_speed(self): """Test vacuum service to set the fan speed.""" group_vacuums = ','.join([ENTITY_VACUUM_BASIC, - ENTITY_VACUUM_COMPLETE]) + ENTITY_VACUUM_COMPLETE, + ENTITY_VACUUM_STATE]) old_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC) old_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + old_state_state = self.hass.states.get(ENTITY_VACUUM_STATE) vacuum.set_fan_speed( self.hass, FAN_SPEEDS[0], entity_id=group_vacuums) @@ -251,6 +332,7 @@ class TestVacuumDemo(unittest.TestCase): self.hass.block_till_done() new_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC) new_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + new_state_state = self.hass.states.get(ENTITY_VACUUM_STATE) self.assertEqual(old_state_basic, new_state_basic) self.assertNotIn(ATTR_FAN_SPEED, new_state_basic.attributes) @@ -261,6 +343,12 @@ class TestVacuumDemo(unittest.TestCase): self.assertEqual(FAN_SPEEDS[0], new_state_complete.attributes[ATTR_FAN_SPEED]) + self.assertNotEqual(old_state_state, new_state_state) + self.assertEqual(FAN_SPEEDS[1], + old_state_state.attributes[ATTR_FAN_SPEED]) + self.assertEqual(FAN_SPEEDS[0], + new_state_state.attributes[ATTR_FAN_SPEED]) + def test_send_command(self): """Test vacuum service to send a command.""" group_vacuums = ','.join([ENTITY_VACUUM_BASIC, diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 28bb31c8482..ccfe1b1aff9 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -135,9 +135,8 @@ class TestHelpersAiohttpClient(unittest.TestCase): @asyncio.coroutine def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client): """Test that it fetches the given url.""" - aioclient_mock.get('http://example.com/mjpeg_stream', content=[ - b'Frame1', b'Frame2', b'Frame3' - ]) + aioclient_mock.get('http://example.com/mjpeg_stream', + content=b'Frame1Frame2Frame3') resp = yield from camera_client.get( '/api/camera_proxy_stream/camera.config_test') @@ -145,7 +144,7 @@ def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client): assert resp.status == 200 assert aioclient_mock.call_count == 1 body = yield from resp.text() - assert body == 'Frame3Frame2Frame1' + assert body == 'Frame1Frame2Frame3' @asyncio.coroutine diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 19185e165bc..9eede7dff9b 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -31,7 +31,7 @@ async def test_single_entry_allowed(hass, flow_conf): flow.hass = hass MockConfigEntry(domain='test').add_to_hass(hass) - result = await flow.async_step_init() + result = await flow.async_step_user() assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'single_instance_allowed' @@ -42,7 +42,7 @@ async def test_user_no_devices_found(hass, flow_conf): flow = config_entries.HANDLERS['test']() flow.hass = hass - result = await flow.async_step_init() + result = await flow.async_step_user() assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'no_devices_found' @@ -54,7 +54,7 @@ async def test_user_no_confirmation(hass, flow_conf): flow.hass = hass flow_conf['discovered'] = True - result = await flow.async_step_init() + result = await flow.async_step_user() assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -90,12 +90,12 @@ async def test_multiple_discoveries(hass, flow_conf): loader.set_component(hass, 'test', MockModule('test')) result = await hass.config_entries.flow.async_init( - 'test', source=data_entry_flow.SOURCE_DISCOVERY, data={}) + 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM # Second discovery result = await hass.config_entries.flow.async_init( - 'test', source=data_entry_flow.SOURCE_DISCOVERY, data={}) + 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={}) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT @@ -105,11 +105,12 @@ async def test_user_init_trumps_discovery(hass, flow_conf): # Discovery starts flow result = await hass.config_entries.flow.async_init( - 'test', source=data_entry_flow.SOURCE_DISCOVERY, data={}) + 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM # User starts flow - result = await hass.config_entries.flow.async_init('test', data={}) + result = await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_USER}, data={}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY # Discovery flow has been aborted diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 540f8d91da9..532197b4072 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -160,8 +160,8 @@ class TestCheckConfig(unittest.TestCase): 'server_host': '0.0.0.0', 'server_port': 8123, 'trusted_networks': [], - 'trusted_proxies': [], - 'use_x_forwarded_for': False} + 'ssl_profile': 'modern', + } assert res['secret_cache'] == {secrets_path: {'http_pw': 'abc123'}} assert res['secrets'] == {'http_pw': 'abc123'} assert normalize_yaml_files(res) == [ diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d7a7ec4b82b..1f6fd8756e6 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -108,7 +108,7 @@ def test_add_entry_calls_setup_entry(hass, manager): VERSION = 1 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_create_entry( title='title', data={ @@ -116,7 +116,8 @@ def test_add_entry_calls_setup_entry(hass, manager): }) with patch.dict(config_entries.HANDLERS, {'comp': TestFlow, 'beer': 5}): - yield from manager.flow.async_init('comp') + yield from manager.flow.async_init( + 'comp', context={'source': config_entries.SOURCE_USER}) yield from hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 @@ -162,7 +163,7 @@ async def test_saving_and_loading(hass): VERSION = 5 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_create_entry( title='Test Title', data={ @@ -171,13 +172,14 @@ async def test_saving_and_loading(hass): ) with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - await hass.config_entries.flow.async_init('test') + await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_USER}) class Test2Flow(data_entry_flow.FlowHandler): VERSION = 3 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_create_entry( title='Test 2 Title', data={ @@ -187,7 +189,8 @@ async def test_saving_and_loading(hass): with patch('homeassistant.config_entries.HANDLERS.get', return_value=Test2Flow): - await hass.config_entries.flow.async_init('test') + await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_USER}) # To trigger the call_later async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) @@ -266,7 +269,7 @@ async def test_discovery_notification(hass): with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): result = await hass.config_entries.flow.async_init( - 'test', source=data_entry_flow.SOURCE_DISCOVERY) + 'test', context={'source': config_entries.SOURCE_DISCOVERY}) await hass.async_block_till_done() state = hass.states.get('persistent_notification.config_entry_discovery') @@ -294,7 +297,7 @@ async def test_discovery_notification_not_created(hass): with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): await hass.config_entries.flow.async_init( - 'test', source=data_entry_flow.SOURCE_DISCOVERY) + 'test', context={'source': config_entries.SOURCE_DISCOVERY}) await hass.async_block_till_done() state = hass.states.get('persistent_notification.config_entry_discovery') diff --git a/tests/test_core.py b/tests/test_core.py index 9de801e0bb4..f23bed6bc8a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -246,8 +246,9 @@ class TestEvent(unittest.TestCase): """Test events.""" now = dt_util.utcnow() data = {'some': 'attr'} + context = ha.Context() event1, event2 = [ - ha.Event('some_type', data, time_fired=now) + ha.Event('some_type', data, time_fired=now, context=context) for _ in range(2) ] diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 894fd4d7194..c5d5bbb50bf 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -12,16 +12,23 @@ def manager(): handlers = Registry() entries = [] - async def async_create_flow(handler_name, *, source, data): + async def async_create_flow(handler_name, *, context, data): handler = handlers.get(handler_name) if handler is None: raise data_entry_flow.UnknownHandler - return handler() + flow = handler() + flow.init_step = context.get('init_step', 'init') \ + if context is not None else 'init' + flow.source = context.get('source') \ + if context is not None else 'user_input' + return flow - async def async_add_entry(result): + async def async_add_entry(context, result): if (result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY): + result['source'] = context.get('source') \ + if context is not None else 'user' entries.append(result) manager = data_entry_flow.FlowManager( @@ -57,12 +64,12 @@ async def test_configure_two_steps(manager): class TestFlow(data_entry_flow.FlowHandler): VERSION = 1 - async def async_step_init(self, user_input=None): + async def async_step_first(self, user_input=None): if user_input is not None: self.init_data = user_input return await self.async_step_second() return self.async_show_form( - step_id='init', + step_id='first', data_schema=vol.Schema([str]) ) @@ -77,7 +84,7 @@ async def test_configure_two_steps(manager): data_schema=vol.Schema([str]) ) - form = await manager.async_init('test') + form = await manager.async_init('test', context={'init_step': 'first'}) with pytest.raises(vol.Invalid): form = await manager.async_configure( @@ -163,7 +170,7 @@ async def test_create_saves_data(manager): assert entry['handler'] == 'test' assert entry['title'] == 'Test Title' assert entry['data'] == 'Test Data' - assert entry['source'] == data_entry_flow.SOURCE_USER + assert entry['source'] == 'user' async def test_discovery_init_flow(manager): @@ -172,7 +179,7 @@ async def test_discovery_init_flow(manager): class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 - async def async_step_discovery(self, info): + async def async_step_init(self, info): return self.async_create_entry(title=info['id'], data=info) data = { @@ -181,7 +188,7 @@ async def test_discovery_init_flow(manager): } await manager.async_init( - 'test', source=data_entry_flow.SOURCE_DISCOVERY, data=data) + 'test', context={'source': 'discovery'}, data=data) assert len(manager.async_progress()) == 0 assert len(manager.mock_created_entries) == 1 @@ -190,4 +197,4 @@ async def test_discovery_init_flow(manager): assert entry['handler'] == 'test' assert entry['title'] == 'hello' assert entry['data'] == data - assert entry['source'] == data_entry_flow.SOURCE_DISCOVERY + assert entry['source'] == 'discovery' diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 0296b8c2fba..813eb84707c 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -7,6 +7,7 @@ from unittest import mock from urllib.parse import parse_qs from aiohttp import ClientSession +from aiohttp.streams import StreamReader from yarl import URL from aiohttp.client_exceptions import ClientResponseError @@ -14,6 +15,15 @@ from aiohttp.client_exceptions import ClientResponseError retype = type(re.compile('')) +def mock_stream(data): + """Mock a stream with data.""" + protocol = mock.Mock(_reading_paused=False) + stream = StreamReader(protocol) + stream.feed_data(data) + stream.feed_eof() + return stream + + class AiohttpClientMocker: """Mock Aiohttp client requests.""" @@ -45,7 +55,7 @@ class AiohttpClientMocker: if not isinstance(url, retype): url = URL(url) if params: - url = url.with_query(params) + url = url.with_query(params) self._mocks.append(AiohttpClientMockResponse( method, url, status, content, cookies, exc, headers)) @@ -130,18 +140,6 @@ class AiohttpClientMockResponse: cookie.value = data self._cookies[name] = cookie - if isinstance(response, list): - self.content = mock.MagicMock() - - @asyncio.coroutine - def read(*argc, **kwargs): - """Read content stream mock.""" - if self.response: - return self.response.pop() - return None - - self.content.read = read - def match_request(self, method, url, params=None): """Test if response answers request.""" if method.lower() != self.method.lower(): @@ -177,6 +175,11 @@ class AiohttpClientMockResponse: """Return dict of cookies.""" return self._cookies + @property + def content(self): + """Return content.""" + return mock_stream(self.response) + @asyncio.coroutine def read(self): """Return mock response.""" diff --git a/tox.ini b/tox.ini index fb36ac6511a..d6ef1981bef 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, py37, py38, lint, pylint, typing +envlist = py35, py36, py37, py38, lint, pylint, typing, cov skip_missing_interpreters = True [testenv] @@ -11,6 +11,22 @@ setenv = ; fail. whitelist_externals = /usr/bin/env install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} +commands = + pytest --timeout=9 --duration=10 {posargs} +deps = + -r{toxinidir}/requirements_test_all.txt + -c{toxinidir}/homeassistant/package_constraints.txt + +[testenv:cov] +basepython = {env:PYTHON3_PATH:python3} +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/homeassistant +; both temper-python and XBee modules have utf8 in their README files +; which get read in from setup.py. If we don't force our locale to a +; utf8 one, tox's env is reset. And the install of these 2 packages +; fail. +whitelist_externals = /usr/bin/env +install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = pytest --timeout=9 --duration=10 --cov --cov-report= {posargs} deps = diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index d0599c2e74c..79072703031 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -10,7 +10,6 @@ LABEL maintainer="Paulus Schoutsen " #ENV INSTALL_OPENALPR no #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no -#ENV INSTALL_PHANTOMJS no #ENV INSTALL_COAP no #ENV INSTALL_SSOCR no #ENV INSTALL_IPERF3 no diff --git a/virtualization/Docker/scripts/phantomjs b/virtualization/Docker/scripts/phantomjs deleted file mode 100755 index 7700b08f293..00000000000 --- a/virtualization/Docker/scripts/phantomjs +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# Sets up phantomjs. - -# Stop on errors -set -e - -PHANTOMJS_VERSION="2.1.1" - -cd /usr/src/app/ -mkdir -p build && cd build - -curl -LSO https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 -tar -xjf phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 -mv phantomjs-$PHANTOMJS_VERSION-linux-x86_64/bin/phantomjs /usr/bin/phantomjs -/usr/bin/phantomjs -v \ No newline at end of file diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index 15504ea57af..65acf92b855 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -7,7 +7,6 @@ set -e INSTALL_TELLSTICK="${INSTALL_TELLSTICK:-yes}" INSTALL_OPENALPR="${INSTALL_OPENALPR:-yes}" INSTALL_LIBCEC="${INSTALL_LIBCEC:-yes}" -INSTALL_PHANTOMJS="${INSTALL_PHANTOMJS:-yes}" INSTALL_SSOCR="${INSTALL_SSOCR:-yes}" # Required debian packages for running hass or components @@ -59,10 +58,6 @@ if [ "$INSTALL_LIBCEC" == "yes" ]; then virtualization/Docker/scripts/libcec fi -if [ "$INSTALL_PHANTOMJS" == "yes" ]; then - virtualization/Docker/scripts/phantomjs -fi - if [ "$INSTALL_SSOCR" == "yes" ]; then virtualization/Docker/scripts/ssocr fi