diff --git a/.coveragerc b/.coveragerc index 90b0a7f475d..73a79c2d87b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -64,6 +64,8 @@ omit = homeassistant/components/cast/* homeassistant/components/*/cast.py + homeassistant/components/cloudflare.py + homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py @@ -341,6 +343,9 @@ omit = homeassistant/components/zoneminder.py homeassistant/components/*/zoneminder.py + homeassistant/components/tuya.py + homeassistant/components/*/tuya.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py @@ -612,6 +617,7 @@ omit = homeassistant/components/sensor/domain_expiry.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/duke_energy.py homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000000..79a65508287 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +multi_line_output=4 diff --git a/.travis.yml b/.travis.yml index b089d3f89be..0a3d710810c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,11 +16,17 @@ matrix: env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 - # - python: "3.6-dev" - # env: TOXENV=py36 - # allow_failures: - # - python: "3.5" - # env: TOXENV=typing + - python: "3.7" + env: TOXENV=py37 + dist: xenial + - python: "3.8-dev" + env: TOXENV=py38 + dist: xenial + if: branch = dev AND type = push + allow_failures: + - python: "3.8-dev" + env: TOXENV=py38 + dist: xenial cache: directories: diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 7d3d2d2af88..496308598dc 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -241,7 +241,7 @@ def cmdline() -> List[str]: def setup_and_run_hass(config_dir: str, - args: argparse.Namespace) -> Optional[int]: + args: argparse.Namespace) -> int: """Set up HASS and run.""" from homeassistant import bootstrap @@ -274,7 +274,7 @@ def setup_and_run_hass(config_dir: str, log_no_color=args.log_no_color) if hass is None: - return None + return -1 if args.open_ui: # Imported here to avoid importing asyncio before monkey patch diff --git a/homeassistant/auth.py b/homeassistant/auth.py deleted file mode 100644 index a4e8ee05943..00000000000 --- a/homeassistant/auth.py +++ /dev/null @@ -1,670 +0,0 @@ -"""Provide an authentication layer for Home Assistant.""" -import asyncio -import binascii -import importlib -import logging -import os -import uuid -from collections import OrderedDict -from datetime import datetime, timedelta - -import attr -import voluptuous as vol -from voluptuous.humanize import humanize_error - -from homeassistant import data_entry_flow, requirements -from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID -from homeassistant.core import callback -from homeassistant.util import dt as dt_util -from homeassistant.util.decorator import Registry - -_LOGGER = logging.getLogger(__name__) - -STORAGE_VERSION = 1 -STORAGE_KEY = 'auth' - -AUTH_PROVIDERS = Registry() - -AUTH_PROVIDER_SCHEMA = vol.Schema({ - vol.Required(CONF_TYPE): str, - vol.Optional(CONF_NAME): str, - # Specify ID if you have two auth providers for same type. - vol.Optional(CONF_ID): str, -}, extra=vol.ALLOW_EXTRA) - -ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) -DATA_REQS = 'auth_reqs_processed' - - -def generate_secret(entropy: int = 32) -> str: - """Generate a secret. - - Backport of secrets.token_hex from Python 3.6 - - Event loop friendly. - """ - return binascii.hexlify(os.urandom(entropy)).decode('ascii') - - -class AuthProvider: - """Provider of user authentication.""" - - DEFAULT_TITLE = 'Unnamed auth provider' - - initialized = False - - def __init__(self, hass, store, config): - """Initialize an auth provider.""" - self.hass = hass - self.store = store - self.config = config - - @property - def id(self): # pylint: disable=invalid-name - """Return id of the auth provider. - - Optional, can be None. - """ - return self.config.get(CONF_ID) - - @property - def type(self): - """Return type of the provider.""" - return self.config[CONF_TYPE] - - @property - def name(self): - """Return the name of the auth provider.""" - return self.config.get(CONF_NAME, self.DEFAULT_TITLE) - - async def async_credentials(self): - """Return all credentials of this provider.""" - return await self.store.credentials_for_provider(self.type, self.id) - - @callback - def async_create_credentials(self, data): - """Create credentials.""" - return Credentials( - auth_provider_type=self.type, - auth_provider_id=self.id, - data=data, - ) - - # Implement by extending class - - async def async_initialize(self): - """Initialize the auth provider. - - Optional. - """ - - async def async_credential_flow(self): - """Return the data flow for logging in with auth provider.""" - raise NotImplementedError - - async def async_get_or_create_credentials(self, flow_result): - """Get credentials based on the flow result.""" - raise NotImplementedError - - async def async_user_meta_for_credentials(self, credentials): - """Return extra user metadata for credentials. - - Will be used to populate info when creating a new user. - """ - return {} - - -@attr.s(slots=True) -class User: - """A user.""" - - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - is_owner = attr.ib(type=bool, default=False) - is_active = attr.ib(type=bool, default=False) - name = attr.ib(type=str, default=None) - - # List of credentials of a user. - credentials = attr.ib(type=list, default=attr.Factory(list), cmp=False) - - # Tokens associated with a user. - refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict), cmp=False) - - -@attr.s(slots=True) -class RefreshToken: - """RefreshToken for a user to grant new access tokens.""" - - user = attr.ib(type=User) - client_id = attr.ib(type=str) - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - access_token_expiration = attr.ib(type=timedelta, - 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 - - -@attr.s(slots=True) -class Credentials: - """Credentials for a user on an auth provider.""" - - auth_provider_type = attr.ib(type=str) - auth_provider_id = attr.ib(type=str) - - # Allow the auth provider to store data to represent their auth. - data = attr.ib(type=dict) - - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - is_new = attr.ib(type=bool, default=True) - - -@attr.s(slots=True) -class Client: - """Client that interacts with Home Assistant on behalf of a user.""" - - name = attr.ib(type=str) - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - secret = attr.ib(type=str, default=attr.Factory(generate_secret)) - redirect_uris = attr.ib(type=list, default=attr.Factory(list)) - - -async def load_auth_provider_module(hass, provider): - """Load an auth provider.""" - try: - module = importlib.import_module( - 'homeassistant.auth_providers.{}'.format(provider)) - except ImportError: - _LOGGER.warning('Unable to find auth provider %s', provider) - return None - - if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): - return module - - processed = hass.data.get(DATA_REQS) - - if processed is None: - processed = hass.data[DATA_REQS] = set() - elif provider in processed: - return module - - req_success = await requirements.async_process_requirements( - hass, 'auth provider {}'.format(provider), module.REQUIREMENTS) - - if not req_success: - return None - - return module - - -async def auth_manager_from_config(hass, provider_configs): - """Initialize an auth manager from config.""" - store = AuthStore(hass) - if provider_configs: - providers = await asyncio.gather( - *[_auth_provider_from_config(hass, store, config) - for config in provider_configs]) - else: - providers = [] - # So returned auth providers are in same order as config - provider_hash = OrderedDict() - for provider in providers: - if provider is None: - continue - - key = (provider.type, provider.id) - - if key in provider_hash: - _LOGGER.error( - 'Found duplicate provider: %s. Please add unique IDs if you ' - 'want to have the same provider twice.', key) - continue - - provider_hash[key] = provider - manager = AuthManager(hass, store, provider_hash) - return manager - - -async def _auth_provider_from_config(hass, store, config): - """Initialize an auth provider from a config.""" - provider_name = config[CONF_TYPE] - module = await load_auth_provider_module(hass, provider_name) - - if module is None: - return None - - try: - config = module.CONFIG_SCHEMA(config) - except vol.Invalid as err: - _LOGGER.error('Invalid configuration for auth provider %s: %s', - provider_name, humanize_error(config, err)) - return None - - return AUTH_PROVIDERS[provider_name](hass, store, config) - - -class AuthManager: - """Manage the authentication for Home Assistant.""" - - def __init__(self, hass, store, providers): - """Initialize the auth manager.""" - self._store = store - self._providers = providers - self.login_flow = data_entry_flow.FlowManager( - hass, self._async_create_login_flow, - self._async_finish_login_flow) - self._access_tokens = {} - - @property - def active(self): - """Return if any auth providers are registered.""" - return bool(self._providers) - - @property - def support_legacy(self): - """ - Return if legacy_api_password auth providers are registered. - - Should be removed when we removed legacy_api_password auth providers. - """ - for provider_type, _ in self._providers: - if provider_type == 'legacy_api_password': - return True - return False - - @property - def async_auth_providers(self): - """Return a list of available auth providers.""" - return self._providers.values() - - async def async_get_user(self, user_id): - """Retrieve a user.""" - return await self._store.async_get_user(user_id) - - async def async_get_or_create_user(self, credentials): - """Get or create a user.""" - return await self._store.async_get_or_create_user( - credentials, self._async_get_auth_provider(credentials)) - - async def async_link_user(self, user, credentials): - """Link credentials to an existing user.""" - await self._store.async_link_user(user, credentials) - - async def async_remove_user(self, user): - """Remove a user.""" - await self._store.async_remove_user(user) - - async def async_create_refresh_token(self, user, client_id): - """Create a new refresh token for a user.""" - return await self._store.async_create_refresh_token(user, client_id) - - async def async_get_refresh_token(self, token): - """Get refresh token by token.""" - return await self._store.async_get_refresh_token(token) - - @callback - def async_create_access_token(self, refresh_token): - """Create a new access token.""" - access_token = AccessToken(refresh_token) - self._access_tokens[access_token.token] = access_token - return access_token - - @callback - def async_get_access_token(self, token): - """Get an access token.""" - tkn = self._access_tokens.get(token) - - if tkn is None: - return None - - if tkn.expired: - self._access_tokens.pop(token) - return None - - return tkn - - async def async_create_client(self, name, *, redirect_uris=None, - no_secret=False): - """Create a new client.""" - return await self._store.async_create_client( - name, redirect_uris, no_secret) - - async def async_get_or_create_client(self, name, *, redirect_uris=None, - no_secret=False): - """Find a client, if not exists, create a new one.""" - for client in await self._store.async_get_clients(): - if client.name == name: - return client - - return await self._store.async_create_client( - name, redirect_uris, no_secret) - - async def async_get_client(self, client_id): - """Get a client.""" - return await self._store.async_get_client(client_id) - - async def _async_create_login_flow(self, handler, *, source, data): - """Create a login flow.""" - auth_provider = self._providers[handler] - - if not auth_provider.initialized: - auth_provider.initialized = True - await auth_provider.async_initialize() - - return await auth_provider.async_credential_flow() - - async def _async_finish_login_flow(self, result): - """Result of a credential login flow.""" - if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - return None - - auth_provider = self._providers[result['handler']] - return await auth_provider.async_get_or_create_credentials( - result['data']) - - @callback - def _async_get_auth_provider(self, credentials): - """Helper to get auth provider from a set of credentials.""" - auth_provider_key = (credentials.auth_provider_type, - credentials.auth_provider_id) - return self._providers[auth_provider_key] - - -class AuthStore: - """Stores authentication info. - - Any mutation to an object should happen inside the auth store. - - The auth store is lazy. It won't load the data from disk until a method is - called that needs it. - """ - - def __init__(self, hass): - """Initialize the auth store.""" - self.hass = hass - self._users = None - self._clients = None - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - - async def credentials_for_provider(self, provider_type, provider_id): - """Return credentials for specific auth provider type and id.""" - if self._users is None: - await self.async_load() - - return [ - credentials - for user in self._users.values() - for credentials in user.credentials - if (credentials.auth_provider_type == provider_type and - credentials.auth_provider_id == provider_id) - ] - - async def async_get_users(self): - """Retrieve all users.""" - if self._users is None: - await self.async_load() - - return list(self._users.values()) - - async def async_get_user(self, user_id): - """Retrieve a user.""" - if self._users is None: - await self.async_load() - - return self._users.get(user_id) - - async def async_get_or_create_user(self, credentials, auth_provider): - """Get or create a new user for given credentials. - - If link_user is passed in, the credentials will be linked to the passed - in user if the credentials are new. - """ - if self._users is None: - await self.async_load() - - # New credentials, store in user - if credentials.is_new: - info = await auth_provider.async_user_meta_for_credentials( - credentials) - # Make owner and activate user if it's the first user. - if self._users: - is_owner = False - is_active = False - else: - is_owner = True - is_active = True - - new_user = User( - is_owner=is_owner, - is_active=is_active, - name=info.get('name'), - ) - self._users[new_user.id] = new_user - await self.async_link_user(new_user, credentials) - return new_user - - for user in self._users.values(): - for creds in user.credentials: - if (creds.auth_provider_type == credentials.auth_provider_type - and creds.auth_provider_id == - credentials.auth_provider_id): - return user - - raise ValueError('We got credentials with ID but found no user') - - async def async_link_user(self, user, credentials): - """Add credentials to an existing user.""" - user.credentials.append(credentials) - await self.async_save() - credentials.is_new = False - - async def async_remove_user(self, user): - """Remove a user.""" - self._users.pop(user.id) - await self.async_save() - - async def async_create_refresh_token(self, user, client_id): - """Create a new token for a user.""" - local_user = await self.async_get_user(user.id) - if local_user is None: - raise ValueError('Invalid user') - - local_client = await self.async_get_client(client_id) - if local_client is None: - raise ValueError('Invalid client_id') - - refresh_token = RefreshToken(user, client_id) - user.refresh_tokens[refresh_token.token] = refresh_token - await self.async_save() - return refresh_token - - async def async_get_refresh_token(self, token): - """Get refresh token by token.""" - if self._users is None: - await self.async_load() - - for user in self._users.values(): - refresh_token = user.refresh_tokens.get(token) - if refresh_token is not None: - return refresh_token - - return None - - async def async_create_client(self, name, redirect_uris, no_secret): - """Create a new client.""" - if self._clients is None: - await self.async_load() - - kwargs = { - 'name': name, - 'redirect_uris': redirect_uris - } - - if no_secret: - kwargs['secret'] = None - - client = Client(**kwargs) - self._clients[client.id] = client - await self.async_save() - return client - - async def async_get_clients(self): - """Return all clients.""" - if self._clients is None: - await self.async_load() - - return list(self._clients.values()) - - async def async_get_client(self, client_id): - """Get a client.""" - if self._clients is None: - await self.async_load() - - return self._clients.get(client_id) - - async def async_load(self): - """Load the users.""" - data = await self._store.async_load() - - # Make sure that we're not overriding data if 2 loads happened at the - # same time - if self._users is not None: - return - - if data is None: - self._users = {} - self._clients = {} - return - - users = { - user_dict['id']: User(**user_dict) for user_dict in data['users'] - } - - for cred_dict in data['credentials']: - users[cred_dict['user_id']].credentials.append(Credentials( - id=cred_dict['id'], - is_new=False, - auth_provider_type=cred_dict['auth_provider_type'], - auth_provider_id=cred_dict['auth_provider_id'], - data=cred_dict['data'], - )) - - refresh_tokens = {} - - for rt_dict in data['refresh_tokens']: - token = RefreshToken( - id=rt_dict['id'], - user=users[rt_dict['user_id']], - client_id=rt_dict['client_id'], - created_at=dt_util.parse_datetime(rt_dict['created_at']), - access_token_expiration=timedelta( - seconds=rt_dict['access_token_expiration']), - token=rt_dict['token'], - ) - 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 = 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) - - clients = { - cl_dict['id']: Client(**cl_dict) for cl_dict in data['clients'] - } - - self._users = users - self._clients = clients - - async def async_save(self): - """Save users.""" - users = [ - { - 'id': user.id, - 'is_owner': user.is_owner, - 'is_active': user.is_active, - 'name': user.name, - } - for user in self._users.values() - ] - - credentials = [ - { - 'id': credential.id, - 'user_id': user.id, - 'auth_provider_type': credential.auth_provider_type, - 'auth_provider_id': credential.auth_provider_id, - 'data': credential.data, - } - for user in self._users.values() - for credential in user.credentials - ] - - refresh_tokens = [ - { - 'id': refresh_token.id, - 'user_id': user.id, - 'client_id': refresh_token.client_id, - 'created_at': refresh_token.created_at.isoformat(), - 'access_token_expiration': - refresh_token.access_token_expiration.total_seconds(), - 'token': refresh_token.token, - } - 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 - ] - - clients = [ - { - 'id': client.id, - 'name': client.name, - 'secret': client.secret, - 'redirect_uris': client.redirect_uris, - } - for client in self._clients.values() - ] - - data = { - 'users': users, - 'clients': clients, - 'credentials': credentials, - 'access_tokens': access_tokens, - 'refresh_tokens': refresh_tokens, - } - - await self._store.async_save(data, delay=1) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py new file mode 100644 index 00000000000..62c416a9883 --- /dev/null +++ b/homeassistant/auth/__init__.py @@ -0,0 +1,243 @@ +"""Provide an authentication layer for Home Assistant.""" +import asyncio +import logging +from collections import OrderedDict + +from homeassistant import data_entry_flow +from homeassistant.core import callback + +from . import models +from . import auth_store +from .providers import auth_provider_from_config + +_LOGGER = logging.getLogger(__name__) + + +async def auth_manager_from_config(hass, provider_configs): + """Initialize an auth manager from config.""" + store = auth_store.AuthStore(hass) + if provider_configs: + providers = await asyncio.gather( + *[auth_provider_from_config(hass, store, config) + for config in provider_configs]) + else: + providers = [] + # So returned auth providers are in same order as config + provider_hash = OrderedDict() + for provider in providers: + if provider is None: + continue + + key = (provider.type, provider.id) + + if key in provider_hash: + _LOGGER.error( + 'Found duplicate provider: %s. Please add unique IDs if you ' + 'want to have the same provider twice.', key) + continue + + provider_hash[key] = provider + manager = AuthManager(hass, store, provider_hash) + return manager + + +class AuthManager: + """Manage the authentication for Home Assistant.""" + + def __init__(self, hass, store, providers): + """Initialize the auth manager.""" + self._store = store + self._providers = providers + 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): + """Return if any auth providers are registered.""" + return bool(self._providers) + + @property + def support_legacy(self): + """ + Return if legacy_api_password auth providers are registered. + + Should be removed when we removed legacy_api_password auth providers. + """ + for provider_type, _ in self._providers: + if provider_type == 'legacy_api_password': + return True + return False + + @property + def auth_providers(self): + """Return a list of available auth providers.""" + return list(self._providers.values()) + + async def async_get_users(self): + """Retrieve all users.""" + return await self._store.async_get_users() + + async def async_get_user(self, user_id): + """Retrieve a user.""" + return await self._store.async_get_user(user_id) + + async def async_create_system_user(self, name): + """Create a system user.""" + return await self._store.async_create_user( + name=name, + system_generated=True, + is_active=True, + ) + + async def async_create_user(self, name): + """Create a user.""" + kwargs = { + 'name': name, + 'is_active': True, + } + + if await self._user_should_be_owner(): + kwargs['is_owner'] = True + + return await self._store.async_create_user(**kwargs) + + async def async_get_or_create_user(self, credentials): + """Get or create a user.""" + if not credentials.is_new: + for user in await self._store.async_get_users(): + for creds in user.credentials: + if creds.id == credentials.id: + return user + + raise ValueError('Unable to find the user.') + + auth_provider = self._async_get_auth_provider(credentials) + + if auth_provider is None: + raise RuntimeError('Credential with unknown provider encountered') + + info = await auth_provider.async_user_meta_for_credentials( + credentials) + + return await self._store.async_create_user( + credentials=credentials, + name=info.get('name'), + is_active=info.get('is_active', False) + ) + + async def async_link_user(self, user, credentials): + """Link credentials to an existing user.""" + await self._store.async_link_user(user, credentials) + + async def async_remove_user(self, user): + """Remove a user.""" + tasks = [ + self.async_remove_credentials(credentials) + for credentials in user.credentials + ] + + if tasks: + await asyncio.wait(tasks) + + await self._store.async_remove_user(user) + + async def async_activate_user(self, user): + """Activate a user.""" + await self._store.async_activate_user(user) + + async def async_deactivate_user(self, user): + """Deactivate a user.""" + if user.is_owner: + raise ValueError('Unable to deactive the owner') + await self._store.async_deactivate_user(user) + + async def async_remove_credentials(self, credentials): + """Remove credentials.""" + provider = self._async_get_auth_provider(credentials) + + if (provider is not None and + hasattr(provider, 'async_will_remove_credentials')): + await provider.async_will_remove_credentials(credentials) + + await self._store.async_remove_credentials(credentials) + + async def async_create_refresh_token(self, user, client_id=None): + """Create a new refresh token for a user.""" + if not user.is_active: + raise ValueError('User is not active') + + if user.system_generated and client_id is not None: + raise ValueError( + 'System generated users cannot have refresh tokens connected ' + 'to a client.') + + if not user.system_generated and client_id is None: + raise ValueError('Client is required to generate a refresh token.') + + return await self._store.async_create_refresh_token(user, client_id) + + async def async_get_refresh_token(self, token): + """Get refresh token by token.""" + return await self._store.async_get_refresh_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 + + @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') + 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) + return None + + return tkn + + async def _async_create_login_flow(self, handler, *, source, data): + """Create a login flow.""" + auth_provider = self._providers[handler] + + return await auth_provider.async_credential_flow() + + async def _async_finish_login_flow(self, result): + """Result of a credential login flow.""" + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return None + + auth_provider = self._providers[result['handler']] + return await auth_provider.async_get_or_create_credentials( + result['data']) + + @callback + def _async_get_auth_provider(self, credentials): + """Helper to get auth provider from a set of credentials.""" + auth_provider_key = (credentials.auth_provider_type, + credentials.auth_provider_id) + return self._providers.get(auth_provider_key) + + async def _user_should_be_owner(self): + """Determine if user should be owner. + + A user should be an owner if it is the first non-system user that is + being created. + """ + for user in await self._store.async_get_users(): + if not user.system_generated: + return False + + return True diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py new file mode 100644 index 00000000000..8fd66d4bbb7 --- /dev/null +++ b/homeassistant/auth/auth_store.py @@ -0,0 +1,240 @@ +"""Storage for auth models.""" +from collections import OrderedDict +from datetime import timedelta + +from homeassistant.util import dt as dt_util + +from . import models + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth' + + +class AuthStore: + """Stores authentication info. + + Any mutation to an object should happen inside the auth store. + + The auth store is lazy. It won't load the data from disk until a method is + called that needs it. + """ + + def __init__(self, hass): + """Initialize the auth store.""" + self.hass = hass + self._users = None + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + async def async_get_users(self): + """Retrieve all users.""" + if self._users is None: + await self.async_load() + + return list(self._users.values()) + + async def async_get_user(self, user_id): + """Retrieve a user by id.""" + if self._users is None: + await self.async_load() + + return self._users.get(user_id) + + async def async_create_user(self, name, is_owner=None, is_active=None, + system_generated=None, credentials=None): + """Create a new user.""" + if self._users is None: + await self.async_load() + + kwargs = { + 'name': name + } + + if is_owner is not None: + kwargs['is_owner'] = is_owner + + if is_active is not None: + kwargs['is_active'] = is_active + + if system_generated is not None: + kwargs['system_generated'] = system_generated + + new_user = models.User(**kwargs) + + self._users[new_user.id] = new_user + + if credentials is None: + await self.async_save() + return new_user + + # Saving is done inside the link. + await self.async_link_user(new_user, credentials) + return new_user + + async def async_link_user(self, user, credentials): + """Add credentials to an existing user.""" + user.credentials.append(credentials) + await self.async_save() + credentials.is_new = False + + async def async_remove_user(self, user): + """Remove a user.""" + self._users.pop(user.id) + await self.async_save() + + async def async_activate_user(self, user): + """Activate a user.""" + user.is_active = True + await self.async_save() + + async def async_deactivate_user(self, user): + """Activate a user.""" + user.is_active = False + await self.async_save() + + async def async_remove_credentials(self, credentials): + """Remove credentials.""" + for user in self._users.values(): + found = None + + for index, cred in enumerate(user.credentials): + if cred is credentials: + found = index + break + + if found is not None: + user.credentials.pop(found) + break + + await self.async_save() + + 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 + await self.async_save() + return refresh_token + + async def async_get_refresh_token(self, token): + """Get refresh token by token.""" + if self._users is None: + await self.async_load() + + for user in self._users.values(): + refresh_token = user.refresh_tokens.get(token) + if refresh_token is not None: + return refresh_token + + return None + + async def async_load(self): + """Load the users.""" + data = await self._store.async_load() + + # Make sure that we're not overriding data if 2 loads happened at the + # same time + if self._users is not None: + return + + users = OrderedDict() + + if data is None: + self._users = users + return + + for user_dict in data['users']: + users[user_dict['id']] = models.User(**user_dict) + + for cred_dict in data['credentials']: + users[cred_dict['user_id']].credentials.append(models.Credentials( + id=cred_dict['id'], + is_new=False, + auth_provider_type=cred_dict['auth_provider_type'], + auth_provider_id=cred_dict['auth_provider_id'], + data=cred_dict['data'], + )) + + refresh_tokens = OrderedDict() + + for rt_dict in data['refresh_tokens']: + token = models.RefreshToken( + id=rt_dict['id'], + user=users[rt_dict['user_id']], + client_id=rt_dict['client_id'], + created_at=dt_util.parse_datetime(rt_dict['created_at']), + access_token_expiration=timedelta( + seconds=rt_dict['access_token_expiration']), + token=rt_dict['token'], + ) + 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) + + self._users = users + + async def async_save(self): + """Save users.""" + users = [ + { + 'id': user.id, + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'name': user.name, + 'system_generated': user.system_generated, + } + for user in self._users.values() + ] + + credentials = [ + { + 'id': credential.id, + 'user_id': user.id, + 'auth_provider_type': credential.auth_provider_type, + 'auth_provider_id': credential.auth_provider_id, + 'data': credential.data, + } + for user in self._users.values() + for credential in user.credentials + ] + + refresh_tokens = [ + { + 'id': refresh_token.id, + 'user_id': user.id, + 'client_id': refresh_token.client_id, + 'created_at': refresh_token.created_at.isoformat(), + 'access_token_expiration': + refresh_token.access_token_expiration.total_seconds(), + 'token': refresh_token.token, + } + 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, + } + + await self._store.async_save(data, delay=1) diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py new file mode 100644 index 00000000000..082d8966275 --- /dev/null +++ b/homeassistant/auth/const.py @@ -0,0 +1,4 @@ +"""Constants for the auth module.""" +from datetime import timedelta + +ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py new file mode 100644 index 00000000000..38e054dc7cf --- /dev/null +++ b/homeassistant/auth/models.py @@ -0,0 +1,75 @@ +"""Auth models.""" +from datetime import datetime, timedelta +import uuid + +import attr + +from homeassistant.util import dt as dt_util + +from .const import ACCESS_TOKEN_EXPIRATION +from .util import generate_secret + + +@attr.s(slots=True) +class User: + """A user.""" + + name = attr.ib(type=str) + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + is_owner = attr.ib(type=bool, default=False) + is_active = attr.ib(type=bool, default=False) + system_generated = attr.ib(type=bool, default=False) + + # List of credentials of a user. + credentials = attr.ib(type=list, default=attr.Factory(list), cmp=False) + + # Tokens associated with a user. + refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict), cmp=False) + + +@attr.s(slots=True) +class RefreshToken: + """RefreshToken for a user to grant new access tokens.""" + + user = attr.ib(type=User) + client_id = attr.ib(type=str) + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) + access_token_expiration = attr.ib(type=timedelta, + 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 + + +@attr.s(slots=True) +class Credentials: + """Credentials for a user on an auth provider.""" + + auth_provider_type = attr.ib(type=str) + auth_provider_id = attr.ib(type=str) + + # Allow the auth provider to store data to represent their auth. + data = attr.ib(type=dict) + + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + is_new = attr.ib(type=bool, default=True) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py new file mode 100644 index 00000000000..68cc1c7edd2 --- /dev/null +++ b/homeassistant/auth/providers/__init__.py @@ -0,0 +1,143 @@ +"""Auth providers for Home Assistant.""" +import importlib +import logging + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant import requirements +from homeassistant.core import callback +from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID +from homeassistant.util.decorator import Registry + +from homeassistant.auth.models import Credentials + +_LOGGER = logging.getLogger(__name__) +DATA_REQS = 'auth_prov_reqs_processed' + +AUTH_PROVIDERS = Registry() + +AUTH_PROVIDER_SCHEMA = vol.Schema({ + vol.Required(CONF_TYPE): str, + vol.Optional(CONF_NAME): str, + # Specify ID if you have two auth providers for same type. + vol.Optional(CONF_ID): str, +}, extra=vol.ALLOW_EXTRA) + + +async def auth_provider_from_config(hass, store, config): + """Initialize an auth provider from a config.""" + provider_name = config[CONF_TYPE] + module = await load_auth_provider_module(hass, provider_name) + + if module is None: + return None + + try: + config = module.CONFIG_SCHEMA(config) + except vol.Invalid as err: + _LOGGER.error('Invalid configuration for auth provider %s: %s', + provider_name, humanize_error(config, err)) + return None + + return AUTH_PROVIDERS[provider_name](hass, store, config) + + +async def load_auth_provider_module(hass, provider): + """Load an auth provider.""" + try: + module = importlib.import_module( + 'homeassistant.auth.providers.{}'.format(provider)) + except ImportError: + _LOGGER.warning('Unable to find auth provider %s', provider) + return None + + if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + return module + + processed = hass.data.get(DATA_REQS) + + if processed is None: + processed = hass.data[DATA_REQS] = set() + elif provider in processed: + return module + + req_success = await requirements.async_process_requirements( + hass, 'auth provider {}'.format(provider), module.REQUIREMENTS) + + if not req_success: + return None + + processed.add(provider) + return module + + +class AuthProvider: + """Provider of user authentication.""" + + DEFAULT_TITLE = 'Unnamed auth provider' + + def __init__(self, hass, store, config): + """Initialize an auth provider.""" + self.hass = hass + self.store = store + self.config = config + + @property + def id(self): # pylint: disable=invalid-name + """Return id of the auth provider. + + Optional, can be None. + """ + return self.config.get(CONF_ID) + + @property + def type(self): + """Return type of the provider.""" + return self.config[CONF_TYPE] + + @property + def name(self): + """Return the name of the auth provider.""" + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + + async def async_credentials(self): + """Return all credentials of this provider.""" + users = await self.store.async_get_users() + return [ + credentials + for user in users + for credentials in user.credentials + if (credentials.auth_provider_type == self.type and + credentials.auth_provider_id == self.id) + ] + + @callback + def async_create_credentials(self, data): + """Create credentials.""" + return Credentials( + auth_provider_type=self.type, + auth_provider_id=self.id, + data=data, + ) + + # Implement by extending class + + async def async_credential_flow(self): + """Return the data flow for logging in with auth provider.""" + raise NotImplementedError + + async def async_get_or_create_credentials(self, flow_result): + """Get credentials based on the flow result.""" + raise NotImplementedError + + async def async_user_meta_for_credentials(self, credentials): + """Return extra user metadata for credentials. + + Will be used to populate info when creating a new user. + + Values to populate: + - name: string + - is_active: boolean + """ + return {} diff --git a/homeassistant/auth_providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py similarity index 68% rename from homeassistant/auth_providers/homeassistant.py rename to homeassistant/auth/providers/homeassistant.py index c4d2021f6ce..d24110a4736 100644 --- a/homeassistant/auth_providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -6,15 +6,29 @@ import hmac import voluptuous as vol -from homeassistant import auth, data_entry_flow +from homeassistant import data_entry_flow +from homeassistant.const import CONF_ID +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.auth.util import generate_secret + +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS STORAGE_VERSION = 1 STORAGE_KEY = 'auth_provider.homeassistant' -CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ -}, extra=vol.PREVENT_EXTRA) + +def _disallow_id(conf): + """Disallow ID in config.""" + if CONF_ID in conf: + raise vol.Invalid( + 'ID is not allowed for the homeassistant auth provider.') + + return conf + + +CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id) class InvalidAuth(HomeAssistantError): @@ -43,7 +57,7 @@ class Data: if data is None: data = { - 'salt': auth.generate_secret(), + 'salt': generate_secret(), 'users': [] } @@ -85,8 +99,8 @@ class Data: hashed = base64.b64encode(hashed).decode() return hashed - def add_user(self, username, password): - """Add a user.""" + def add_auth(self, username, password): + """Add a new authenticated user/pass.""" if any(user['username'] == username for user in self.users): raise InvalidUser @@ -95,8 +109,22 @@ class Data: 'password': self.hash_password(password, True), }) + @callback + def async_remove_auth(self, username): + """Remove authentication.""" + index = None + for i, user in enumerate(self.users): + if user['username'] == username: + index = i + break + + if index is None: + raise InvalidUser + + self.users.pop(index) + def change_password(self, username, new_password): - """Update the password of a user. + """Update the password. Raises InvalidUser if user cannot be found. """ @@ -112,22 +140,33 @@ class Data: await self._store.async_save(self._data) -@auth.AUTH_PROVIDERS.register('homeassistant') -class HassAuthProvider(auth.AuthProvider): +@AUTH_PROVIDERS.register('homeassistant') +class HassAuthProvider(AuthProvider): """Auth provider based on a local storage of users in HASS config dir.""" DEFAULT_TITLE = 'Home Assistant Local' + data = None + + async def async_initialize(self): + """Initialize the auth provider.""" + if self.data is not None: + return + + self.data = Data(self.hass) + await self.data.async_load() + async def async_credential_flow(self): """Return a flow to login.""" return LoginFlow(self) async def async_validate_login(self, username, password): """Helper to validate a username and password.""" - data = Data(self.hass) - await data.async_load() + if self.data is None: + await self.async_initialize() + await self.hass.async_add_executor_job( - data.validate_login, username, password) + self.data.validate_login, username, password) async def async_get_or_create_credentials(self, flow_result): """Get credentials based on the flow result.""" @@ -142,6 +181,25 @@ class HassAuthProvider(auth.AuthProvider): 'username': username }) + async def async_user_meta_for_credentials(self, credentials): + """Get extra info for this credential.""" + return { + 'name': credentials.data['username'], + 'is_active': True, + } + + async def async_will_remove_credentials(self, credentials): + """When credentials get removed, also remove the auth.""" + if self.data is None: + await self.async_initialize() + + try: + self.data.async_remove_auth(credentials.data['username']) + await self.data.async_save() + except InvalidUser: + # Can happen if somehow we didn't clean up a credential + pass + class LoginFlow(data_entry_flow.FlowHandler): """Handler for the login flow.""" diff --git a/homeassistant/auth_providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py similarity index 89% rename from homeassistant/auth_providers/insecure_example.py rename to homeassistant/auth/providers/insecure_example.py index a8e8cd0cb0e..c86c8eb71f1 100644 --- a/homeassistant/auth_providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -5,9 +5,11 @@ import hmac import voluptuous as vol from homeassistant.exceptions import HomeAssistantError -from homeassistant import auth, data_entry_flow +from homeassistant import data_entry_flow from homeassistant.core import callback +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS + USER_SCHEMA = vol.Schema({ vol.Required('username'): str, @@ -16,7 +18,7 @@ USER_SCHEMA = vol.Schema({ }) -CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ vol.Required('users'): [USER_SCHEMA] }, extra=vol.PREVENT_EXTRA) @@ -25,8 +27,8 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -@auth.AUTH_PROVIDERS.register('insecure_example') -class ExampleAuthProvider(auth.AuthProvider): +@AUTH_PROVIDERS.register('insecure_example') +class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" async def async_credential_flow(self): @@ -73,14 +75,16 @@ class ExampleAuthProvider(auth.AuthProvider): Will be used to populate info when creating a new user. """ username = credentials.data['username'] + info = { + 'is_active': True, + } for user in self.config['users']: if user['username'] == username: - return { - 'name': user.get('name') - } + info['name'] = user.get('name') + break - return {} + return info class LoginFlow(data_entry_flow.FlowHandler): diff --git a/homeassistant/auth_providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py similarity index 89% rename from homeassistant/auth_providers/legacy_api_password.py rename to homeassistant/auth/providers/legacy_api_password.py index 510cc4d0279..1f92fb60f13 100644 --- a/homeassistant/auth_providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -9,15 +9,18 @@ import hmac import voluptuous as vol from homeassistant.exceptions import HomeAssistantError -from homeassistant import auth, data_entry_flow +from homeassistant import data_entry_flow from homeassistant.core import callback +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS + + USER_SCHEMA = vol.Schema({ vol.Required('username'): str, }) -CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ }, extra=vol.PREVENT_EXTRA) LEGACY_USER = 'homeassistant' @@ -27,8 +30,8 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -@auth.AUTH_PROVIDERS.register('legacy_api_password') -class LegacyApiPasswordAuthProvider(auth.AuthProvider): +@AUTH_PROVIDERS.register('legacy_api_password') +class LegacyApiPasswordAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" DEFAULT_TITLE = 'Legacy API Password' @@ -67,7 +70,10 @@ class LegacyApiPasswordAuthProvider(auth.AuthProvider): Will be used to populate info when creating a new user. """ - return {'name': LEGACY_USER} + return { + 'name': LEGACY_USER, + 'is_active': True, + } class LoginFlow(data_entry_flow.FlowHandler): diff --git a/homeassistant/auth/util.py b/homeassistant/auth/util.py new file mode 100644 index 00000000000..402caae4618 --- /dev/null +++ b/homeassistant/auth/util.py @@ -0,0 +1,13 @@ +"""Auth utils.""" +import binascii +import os + + +def generate_secret(entropy: int = 32) -> str: + """Generate a secret. + + Backport of secrets.token_hex from Python 3.6 + + Event loop friendly. + """ + return binascii.hexlify(os.urandom(entropy)).decode('ascii') diff --git a/homeassistant/auth_providers/__init__.py b/homeassistant/auth_providers/__init__.py deleted file mode 100644 index 4705e7580ca..00000000000 --- a/homeassistant/auth_providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Auth providers for Home Assistant.""" diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0a71c2887b1..a190aea9fa8 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -28,9 +28,8 @@ ERROR_LOG_FILENAME = 'home-assistant.log' # hass.data key for logging information. DATA_LOGGING = 'logging' -FIRST_INIT_COMPONENT = set(( - 'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', - 'introduction', 'frontend', 'history')) +FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', + 'logger', 'introduction', 'frontend', 'history'} def from_config_dict(config: Dict[str, Any], @@ -95,7 +94,8 @@ async def async_from_config_dict(config: Dict[str, Any], conf_util.async_log_exception(ex, 'homeassistant', core_config, hass) return None - await hass.async_add_job(conf_util.process_ha_config_upgrade, hass) + await hass.async_add_executor_job( + conf_util.process_ha_config_upgrade, hass) hass.config.skip_pip = skip_pip if skip_pip: @@ -137,7 +137,7 @@ async def async_from_config_dict(config: Dict[str, Any], for component in components: if component not in FIRST_INIT_COMPONENT: continue - hass.async_add_job(async_setup_component(hass, component, config)) + hass.async_create_task(async_setup_component(hass, component, config)) await hass.async_block_till_done() @@ -145,7 +145,7 @@ async def async_from_config_dict(config: Dict[str, Any], for component in components: if component in FIRST_INIT_COMPONENT: continue - hass.async_add_job(async_setup_component(hass, component, config)) + hass.async_create_task(async_setup_component(hass, component, config)) await hass.async_block_till_done() @@ -162,7 +162,8 @@ def from_config_file(config_path: str, skip_pip: bool = True, log_rotate_days: Any = None, log_file: Any = None, - log_no_color: bool = False): + log_no_color: bool = False)\ + -> Optional[core.HomeAssistant]: """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -187,7 +188,8 @@ async def async_from_config_file(config_path: str, skip_pip: bool = True, log_rotate_days: Any = None, log_file: Any = None, - log_no_color: bool = False): + log_no_color: bool = False)\ + -> Optional[core.HomeAssistant]: """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -204,7 +206,7 @@ async def async_from_config_file(config_path: str, log_no_color) try: - config_dict = await hass.async_add_job( + config_dict = await hass.async_add_executor_job( conf_util.load_yaml_config_file, config_path) except HomeAssistantError as err: _LOGGER.error("Error loading %s: %s", config_path, err) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f81d2ef1037..84a72945a7e 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -121,7 +121,7 @@ def alarm_arm_custom_bypass(hass, code=None, entity_id=None): @asyncio.coroutine def async_setup(hass, config): """Track states and offer events for sensors.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) yield from component.async_setup(config) @@ -154,6 +154,17 @@ def async_setup(hass, config): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +# pylint: disable=no-self-use class AlarmControlPanel(Entity): """An abstract class for alarm control devices.""" diff --git a/homeassistant/components/alarm_control_panel/homematicip_cloud.py b/homeassistant/components/alarm_control_panel/homematicip_cloud.py new file mode 100644 index 00000000000..893fa76c44b --- /dev/null +++ b/homeassistant/components/alarm_control_panel/homematicip_cloud.py @@ -0,0 +1,88 @@ +""" +Support for HomematicIP alarm control panel. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.homematicip_cloud/ +""" + +import logging + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED) +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) + + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + +HMIP_OPEN = 'OPEN' +HMIP_ZONE_AWAY = 'EXTERNAL' +HMIP_ZONE_HOME = 'INTERNAL' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the HomematicIP alarm control devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP alarm control panel from a config entry.""" + from homematicip.aio.group import AsyncSecurityZoneGroup + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for group in home.groups: + if isinstance(group, AsyncSecurityZoneGroup): + devices.append(HomematicipSecurityZone(home, group)) + + if devices: + async_add_devices(devices) + + +class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): + """Representation of an HomematicIP security zone group.""" + + def __init__(self, home, device): + """Initialize the security zone group.""" + device.modelType = 'Group-SecurityZone' + device.windowState = '' + super().__init__(home, device) + + @property + def state(self): + """Return the state of the device.""" + if self._device.active: + if (self._device.sabotage or self._device.motionDetected or + self._device.windowState == HMIP_OPEN): + return STATE_ALARM_TRIGGERED + + if self._device.label == HMIP_ZONE_HOME: + return STATE_ALARM_ARMED_HOME + return STATE_ALARM_ARMED_AWAY + + return STATE_ALARM_DISARMED + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self._home.set_security_zones_activation(False, False) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + await self._home.set_security_zones_activation(True, False) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self._home.set_security_zones_activation(True, True) + + @property + def device_state_attributes(self): + """Return the state attributes of the alarm control device.""" + # The base class is loading the battery property, but device doesn't + # have this property - base class needs clean-up. + return None diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index ff2d4adf30d..9b7da71a293 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -270,11 +270,14 @@ class _AlexaInterface(object): """Return properties serialized for an API response.""" for prop in self.properties_supported(): prop_name = prop['name'] - yield { - 'name': prop_name, - 'namespace': self.name(), - 'value': self.get_property(prop_name), - } + # pylint: disable=assignment-from-no-return + prop_value = self.get_property(prop_name) + if prop_value is not None: + yield { + 'name': prop_name, + 'namespace': self.name(), + 'value': prop_value, + } class _AlexaPowerController(_AlexaInterface): @@ -438,14 +441,17 @@ class _AlexaThermostatController(_AlexaInterface): unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT] temp = None if name == 'targetSetpoint': - temp = self.entity.attributes.get(ATTR_TEMPERATURE) + temp = self.entity.attributes.get(climate.ATTR_TEMPERATURE) elif name == 'lowerSetpoint': temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) elif name == 'upperSetpoint': temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) - if temp is None: + else: raise _UnsupportedProperty(name) + if temp is None: + return None + return { 'value': float(temp), 'scale': API_TEMP_UNITS[unit], diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index fa58c9b0baa..475e43e55a4 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.dispatcher import dispatcher_send -REQUIREMENTS = ['pyarlo==0.1.8'] +REQUIREMENTS = ['pyarlo==0.1.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 0f7295a41e0..435555c2e31 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -102,6 +102,7 @@ a limited expiration. "token_type": "Bearer" } """ +from datetime import timedelta import logging import uuid @@ -112,13 +113,22 @@ from homeassistant import data_entry_flow from homeassistant.core import callback from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView) +from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.util import dt as dt_util + +from . import indieauth -from .client import verify_client DOMAIN = 'auth' DEPENDENCIES = ['http'] + +WS_TYPE_CURRENT_USER = 'auth/current_user' +SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CURRENT_USER, +}) + _LOGGER = logging.getLogger(__name__) @@ -133,6 +143,11 @@ async def async_setup(hass, config): hass.http.register_view(GrantTokenView(retrieve_credentials)) hass.http.register_view(LinkUserView(retrieve_credentials)) + hass.components.websocket_api.async_register_command( + WS_TYPE_CURRENT_USER, websocket_current_user, + SCHEMA_WS_CURRENT_USER + ) + return True @@ -143,14 +158,13 @@ class AuthProvidersView(HomeAssistantView): name = 'api:auth:providers' requires_auth = False - @verify_client - async def get(self, request, client): + async def get(self, request): """Get available auth providers.""" return self.json([{ 'name': provider.name, 'id': provider.id, 'type': provider.type, - } for provider in request.app['hass'].auth.async_auth_providers]) + } for provider in request.app['hass'].auth.auth_providers]) class LoginFlowIndexView(FlowManagerIndexView): @@ -164,16 +178,16 @@ class LoginFlowIndexView(FlowManagerIndexView): """Do not allow index of flows in progress.""" return aiohttp.web.Response(status=405) - # pylint: disable=arguments-differ - @verify_client @RequestDataValidator(vol.Schema({ + vol.Required('client_id'): str, vol.Required('handler'): vol.Any(str, list), vol.Required('redirect_uri'): str, })) - async def post(self, request, client, data): + async def post(self, request, data): """Create a new login flow.""" - if data['redirect_uri'] not in client.redirect_uris: - return self.json_message('invalid redirect uri', ) + if not indieauth.verify_redirect_uri(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) @@ -191,16 +205,20 @@ class LoginFlowResourceView(FlowManagerResourceView): super().__init__(flow_mgr) self._store_credentials = store_credentials - # pylint: disable=arguments-differ - async def get(self, request): + async def get(self, request, flow_id): """Do not allow getting status of a flow in progress.""" return self.json_message('Invalid flow specified', 404) - # pylint: disable=arguments-differ - @verify_client - @RequestDataValidator(vol.Schema(dict), allow_empty=True) - async def post(self, request, client, flow_id, data): + @RequestDataValidator(vol.Schema({ + 'client_id': str + }, extra=vol.ALLOW_EXTRA)) + async def post(self, request, flow_id, data): """Handle progressing a login flow request.""" + client_id = data.pop('client_id') + + if not indieauth.verify_client_id(client_id): + return self.json_message('Invalid client id', 400) + try: result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: @@ -212,7 +230,7 @@ class LoginFlowResourceView(FlowManagerResourceView): return self.json(self._prepare_result_json(result)) result.pop('data') - result['result'] = self._store_credentials(client.id, result['result']) + result['result'] = self._store_credentials(client_id, result['result']) return self.json(result) @@ -223,25 +241,32 @@ class GrantTokenView(HomeAssistantView): url = '/auth/token' name = 'api:auth:token' requires_auth = False + cors_allowed = True def __init__(self, retrieve_credentials): """Initialize the grant token view.""" self._retrieve_credentials = retrieve_credentials - @verify_client - async def post(self, request, client): + async def post(self, request): """Grant a token.""" hass = request.app['hass'] data = await request.post() + + client_id = data.get('client_id') + if client_id is None or not indieauth.verify_client_id(client_id): + return self.json({ + 'error': 'invalid_request', + 'error_description': 'Invalid client id', + }, status_code=400) + grant_type = data.get('grant_type') if grant_type == 'authorization_code': - return await self._async_handle_auth_code( - hass, client.id, data) + return await self._async_handle_auth_code(hass, client_id, data) elif grant_type == 'refresh_token': return await self._async_handle_refresh_token( - hass, client.id, data) + hass, client_id, data) return self.json({ 'error': 'unsupported_grant_type', @@ -261,9 +286,17 @@ class GrantTokenView(HomeAssistantView): if credentials is None: return self.json({ 'error': 'invalid_request', + 'error_description': 'Invalid code', }, status_code=400) user = await hass.auth.async_get_or_create_user(credentials) + + if not user.is_active: + return self.json({ + 'error': 'access_denied', + 'error_description': 'User is not active', + }, status_code=403) + refresh_token = await hass.auth.async_create_refresh_token(user, client_id) access_token = hass.auth.async_create_access_token(refresh_token) @@ -340,12 +373,43 @@ def _create_cred_store(): def store_credentials(client_id, credentials): """Store credentials and return a code to retrieve it.""" code = uuid.uuid4().hex - temp_credentials[(client_id, code)] = credentials + temp_credentials[(client_id, code)] = (dt_util.utcnow(), credentials) return code @callback def retrieve_credentials(client_id, code): """Retrieve credentials.""" - return temp_credentials.pop((client_id, code), None) + key = (client_id, code) + + if key not in temp_credentials: + return None + + created, credentials = temp_credentials.pop(key) + + # OAuth 4.2.1 + # The authorization code MUST expire shortly after it is issued to + # mitigate the risk of leaks. A maximum authorization code lifetime of + # 10 minutes is RECOMMENDED. + if dt_util.utcnow() - created < timedelta(minutes=10): + return credentials + + return None return store_credentials, retrieve_credentials + + +@callback +def websocket_current_user(hass, connection, msg): + """Return the current user.""" + user = connection.request.get('hass_user') + + if user is None: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'no_user', 'Not authenticated as a user')) + return + + connection.to_write.put_nowait(websocket_api.result_message(msg['id'], { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + })) diff --git a/homeassistant/components/auth/client.py b/homeassistant/components/auth/client.py deleted file mode 100644 index 122c3032188..00000000000 --- a/homeassistant/components/auth/client.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Helpers to resolve client ID/secret.""" -import base64 -from functools import wraps -import hmac - -import aiohttp.hdrs - - -def verify_client(method): - """Decorator to verify client id/secret on requests.""" - @wraps(method) - async def wrapper(view, request, *args, **kwargs): - """Verify client id/secret before doing request.""" - client = await _verify_client(request) - - if client is None: - return view.json({ - 'error': 'invalid_client', - }, status_code=401) - - return await method( - view, request, *args, **kwargs, client=client) - - return wrapper - - -async def _verify_client(request): - """Method to verify the client id/secret in consistent time. - - By using a consistent time for looking up client id and comparing the - secret, we prevent attacks by malicious actors trying different client ids - and are able to derive from the time it takes to process the request if - they guessed the client id correctly. - """ - if aiohttp.hdrs.AUTHORIZATION not in request.headers: - return None - - auth_type, auth_value = \ - request.headers.get(aiohttp.hdrs.AUTHORIZATION).split(' ', 1) - - if auth_type != 'Basic': - return None - - decoded = base64.b64decode(auth_value).decode('utf-8') - try: - client_id, client_secret = decoded.split(':', 1) - except ValueError: - # If no ':' in decoded - client_id, client_secret = decoded, None - - return await async_secure_get_client( - request.app['hass'], client_id, client_secret) - - -async def async_secure_get_client(hass, client_id, client_secret): - """Get a client id/secret in consistent time.""" - client = await hass.auth.async_get_client(client_id) - - if client is None: - if client_secret is not None: - # Still do a compare so we run same time as if a client was found. - hmac.compare_digest(client_secret.encode('utf-8'), - client_secret.encode('utf-8')) - return None - - if client.secret is None: - return client - - elif client_secret is None: - # Still do a compare so we run same time as if a secret was passed. - hmac.compare_digest(client.secret.encode('utf-8'), - client.secret.encode('utf-8')) - return None - - elif hmac.compare_digest(client_secret.encode('utf-8'), - client.secret.encode('utf-8')): - return client - - return None diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py new file mode 100644 index 00000000000..ef7f8a9b292 --- /dev/null +++ b/homeassistant/components/auth/indieauth.py @@ -0,0 +1,130 @@ +"""Helpers to resolve client ID/secret.""" +from ipaddress import ip_address, ip_network +from urllib.parse import urlparse + +# IP addresses of loopback interfaces +ALLOWED_IPS = ( + ip_address('127.0.0.1'), + ip_address('::1'), +) + +# RFC1918 - Address allocation for Private Internets +ALLOWED_NETWORKS = ( + ip_network('10.0.0.0/8'), + ip_network('172.16.0.0/12'), + ip_network('192.168.0.0/16'), +) + + +def verify_redirect_uri(client_id, redirect_uri): + """Verify that the client and redirect uri match.""" + try: + client_id_parts = _parse_client_id(client_id) + except ValueError: + return False + + 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 ( + client_id_parts.scheme == redirect_parts.scheme and + client_id_parts.netloc == redirect_parts.netloc + ) + + +def verify_client_id(client_id): + """Verify that the client id is valid.""" + try: + _parse_client_id(client_id) + return True + except ValueError: + return False + + +def _parse_url(url): + """Parse a url in parts and canonicalize according to IndieAuth.""" + parts = urlparse(url) + + # Canonicalize a url according to IndieAuth 3.2. + + # SHOULD convert the hostname to lowercase + parts = parts._replace(netloc=parts.netloc.lower()) + + # If a URL with no path component is ever encountered, + # it MUST be treated as if it had the path /. + if parts.path == '': + parts = parts._replace(path='/') + + return parts + + +def _parse_client_id(client_id): + """Test if client id is a valid URL according to IndieAuth section 3.2. + + https://indieauth.spec.indieweb.org/#client-identifier + """ + parts = _parse_url(client_id) + + # Client identifier URLs + # MUST have either an https or http scheme + if parts.scheme not in ('http', 'https'): + raise ValueError() + + # MUST contain a path component + # Handled by url canonicalization. + + # MUST NOT contain single-dot or double-dot path segments + if any(segment in ('.', '..') for segment in parts.path.split('/')): + raise ValueError( + 'Client ID cannot contain single-dot or double-dot path segments') + + # MUST NOT contain a fragment component + if parts.fragment != '': + raise ValueError('Client ID cannot contain a fragment') + + # MUST NOT contain a username or password component + if parts.username is not None: + raise ValueError('Client ID cannot contain username') + + if parts.password is not None: + raise ValueError('Client ID cannot contain password') + + # MAY contain a port + try: + # parts raises ValueError when port cannot be parsed as int + parts.port + except ValueError: + raise ValueError('Client ID contains invalid port') + + # Additionally, hostnames + # MUST be domain names or a loopback interface and + # MUST NOT be IPv4 or IPv6 addresses except for IPv4 127.0.0.1 + # or IPv6 [::1] + + # We are not goint to follow the spec here. We are going to allow + # any internal network IP to be used inside a client id. + + address = None + + try: + netloc = parts.netloc + + # Strip the [, ] from ipv6 addresses before parsing + if netloc[0] == '[' and netloc[-1] == ']': + netloc = netloc[1:-1] + + address = ip_address(netloc) + except ValueError: + # Not an ip address + pass + + if (address is None or + address in ALLOWED_IPS or + any(address in network for network in ALLOWED_NETWORKS)): + return parts + + raise ValueError('Hostname should be a domain name or local IP address') diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index 6f59da0755a..0a370d754ee 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -5,9 +5,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/binary_sensor.deconz/ """ from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.deconz import ( - CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, - DATA_DECONZ_UNSUB) +from homeassistant.components.deconz.const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -62,7 +62,8 @@ class DeconzBinarySensor(BinarySensorDevice): """ if reason['state'] or \ 'reachable' in reason['attr'] or \ - 'battery' in reason['attr']: + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: self.async_schedule_update_ha_state() @property @@ -107,6 +108,8 @@ class DeconzBinarySensor(BinarySensorDevice): attr = {} if self._sensor.battery: attr[ATTR_BATTERY_LEVEL] = self._sensor.battery + if self._sensor.on is not None: + attr[ATTR_ON] = self._sensor.on if self._sensor.type in PRESENCE and self._sensor.dark is not None: - attr['dark'] = self._sensor.dark + attr[ATTR_DARK] = self._sensor.dark return attr diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py index 40ffe498402..6966f61129c 100644 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -9,8 +9,8 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) DEPENDENCIES = ['homematicip_cloud'] @@ -21,17 +21,18 @@ ATTR_EVENT_DELAY = 'event_delay' ATTR_MOTION_DETECTED = 'motion_detected' ATTR_ILLUMINATION = 'illumination' -HMIP_OPEN = 'open' - async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the HomematicIP binary sensor devices.""" + """Set up the binary sensor devices.""" + pass + + +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) - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: if isinstance(device, ShutterContact): @@ -58,11 +59,13 @@ class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): @property def is_on(self): """Return true if the shutter contact is on/open.""" + from homematicip.base.enums import WindowState + if self._device.sabotage: return True if self._device.windowState is None: return None - return self._device.windowState.lower() == HMIP_OPEN + return self._device.windowState == WindowState.OPEN class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index e84009301ab..4f2ea408e7f 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -23,7 +23,7 @@ DEPENDENCIES = ['ring'] _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = timedelta(seconds=10) # Sensor types: Name, category, device_class SENSOR_TYPES = { diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 9716e46bc03..35566b0cbed 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -41,8 +41,9 @@ async def async_setup(hass, config): hass.http.register_view(CalendarListView(component)) hass.http.register_view(CalendarEventView(component)) - await hass.components.frontend.async_register_built_in_panel( - 'calendar', 'calendar', 'hass:calendar') + # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289 + # await hass.components.frontend.async_register_built_in_panel( + # 'calendar', 'calendar', 'hass:calendar') await component.async_setup(config) return True diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index 87893125e6f..279fb1e2694 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -4,7 +4,6 @@ Support for Google Calendar Search binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.google_calendar/ """ -# pylint: disable=import-error import logging from datetime import timedelta diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 14550dab899..22354b51956 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -66,8 +66,8 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail' SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - 'type': WS_TYPE_CAMERA_THUMBNAIL, - 'entity_id': cv.entity_id + vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL, + vol.Required('entity_id'): cv.entity_id }) diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 3ae47ba5dee..32f8e15748d 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -25,9 +25,7 @@ _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['onvif-py3==0.1.3', 'suds-py3==1.3.3.0', - 'http://github.com/tgaugry/suds-passworddigest-py3' - '/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip' - '#suds-passworddigest-py3==0.1.2a'] + 'suds-passworddigest-homeassistant==0.1.2a0.dev0'] DEPENDENCIES = ['ffmpeg'] DEFAULT_NAME = 'ONVIF Camera' DEFAULT_PORT = 5000 diff --git a/homeassistant/components/camera/push.py b/homeassistant/components/camera/push.py new file mode 100644 index 00000000000..def5c53dd3f --- /dev/null +++ b/homeassistant/components/camera/push.py @@ -0,0 +1,170 @@ +""" +Camera platform that receives images through HTTP POST. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/camera.push/ +""" +import logging + +from collections import deque +from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ + STATE_IDLE, STATE_RECORDING +from homeassistant.core import callback +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.const import CONF_NAME, CONF_TIMEOUT, HTTP_BAD_REQUEST +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_point_in_utc_time +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +CONF_BUFFER_SIZE = 'buffer' +CONF_IMAGE_FIELD = 'field' + +DEFAULT_NAME = "Push Camera" + +ATTR_FILENAME = 'filename' +ATTR_LAST_TRIP = 'last_trip' + +PUSH_CAMERA_DATA = 'push_camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_BUFFER_SIZE, default=1): cv.positive_int, + vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Push Camera platform.""" + if PUSH_CAMERA_DATA not in hass.data: + hass.data[PUSH_CAMERA_DATA] = {} + + cameras = [PushCamera(config[CONF_NAME], + config[CONF_BUFFER_SIZE], + config[CONF_TIMEOUT])] + + hass.http.register_view(CameraPushReceiver(hass, + config[CONF_IMAGE_FIELD])) + + async_add_devices(cameras) + + +class CameraPushReceiver(HomeAssistantView): + """Handle pushes from remote camera.""" + + url = "/api/camera_push/{entity_id}" + name = 'api:camera_push:camera_entity' + + def __init__(self, hass, image_field): + """Initialize CameraPushReceiver with camera entity.""" + self._cameras = hass.data[PUSH_CAMERA_DATA] + self._image = image_field + + async def post(self, request, entity_id): + """Accept the POST from Camera.""" + _camera = self._cameras.get(entity_id) + + if _camera is None: + _LOGGER.error("Unknown %s", entity_id) + return self.json_message('Unknown {}'.format(entity_id), + HTTP_BAD_REQUEST) + + try: + data = await request.post() + _LOGGER.debug("Received Camera push: %s", data[self._image]) + await _camera.update_image(data[self._image].file.read(), + data[self._image].filename) + except ValueError as value_error: + _LOGGER.error("Unknown value %s", value_error) + return self.json_message('Invalid POST', HTTP_BAD_REQUEST) + except KeyError as key_error: + _LOGGER.error('In your POST message %s', key_error) + return self.json_message('{} missing'.format(self._image), + HTTP_BAD_REQUEST) + + +class PushCamera(Camera): + """The representation of a Push camera.""" + + def __init__(self, name, buffer_size, timeout): + """Initialize push camera component.""" + super().__init__() + self._name = name + self._last_trip = None + self._filename = None + self._expired_listener = None + self._state = STATE_IDLE + self._timeout = timeout + self.queue = deque([], buffer_size) + self._current_image = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.data[PUSH_CAMERA_DATA][self.entity_id] = self + + @property + def state(self): + """Current state of the camera.""" + return self._state + + async def update_image(self, image, filename): + """Update the camera image.""" + if self._state == STATE_IDLE: + self._state = STATE_RECORDING + self._last_trip = dt_util.utcnow() + self.queue.clear() + + self._filename = filename + self.queue.appendleft(image) + + @callback + def reset_state(now): + """Set state to idle after no new images for a period of time.""" + self._state = STATE_IDLE + self._expired_listener = None + _LOGGER.debug("Reset state") + self.async_schedule_update_ha_state() + + if self._expired_listener: + self._expired_listener() + + self._expired_listener = async_track_point_in_utc_time( + self.hass, reset_state, dt_util.utcnow() + self._timeout) + + self.async_schedule_update_ha_state() + + async def async_camera_image(self): + """Return a still image response.""" + if self.queue: + if self._state == STATE_IDLE: + self.queue.rotate(1) + self._current_image = self.queue[0] + + return self._current_image + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + name: value for name, value in ( + (ATTR_LAST_TRIP, self._last_trip), + (ATTR_FILENAME, self._filename), + ) if value is not None + } diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/climate/homematicip_cloud.py b/homeassistant/components/climate/homematicip_cloud.py index bf96f1f746d..8cf47159c10 100644 --- a/homeassistant/components/climate/homematicip_cloud.py +++ b/homeassistant/components/climate/homematicip_cloud.py @@ -12,8 +12,8 @@ from homeassistant.components.climate import ( STATE_AUTO, STATE_MANUAL) from homeassistant.const import TEMP_CELSIUS from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) _LOGGER = logging.getLogger(__name__) @@ -30,12 +30,14 @@ HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()} async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HomematicIP climate devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP climate from a config entry.""" from homematicip.group import HeatingGroup - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] - + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.groups: if isinstance(device, HeatingGroup): diff --git a/homeassistant/components/cloudflare.py b/homeassistant/components/cloudflare.py new file mode 100644 index 00000000000..ae400ca6385 --- /dev/null +++ b/homeassistant/components/cloudflare.py @@ -0,0 +1,77 @@ +""" +Update the IP addresses of your Cloudflare DNS records. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/cloudflare/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['pycfdns==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RECORDS = 'records' + +DOMAIN = 'cloudflare' + +INTERVAL = timedelta(minutes=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ZONE): cv.string, + vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Cloudflare component.""" + from pycfdns import CloudflareUpdater + + cfupdate = CloudflareUpdater() + email = config[DOMAIN][CONF_EMAIL] + key = config[DOMAIN][CONF_API_KEY] + zone = config[DOMAIN][CONF_ZONE] + records = config[DOMAIN][CONF_RECORDS] + + def update_records_interval(now): + """Set up recurring update.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + def update_records_service(now): + """Set up service for manual trigger.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + track_time_interval(hass, update_records_interval, INTERVAL) + hass.services.register( + DOMAIN, 'update_records', update_records_service) + return True + + +def _update_cloudflare(cfupdate, email, key, zone, records): + """Update DNS records for a given zone.""" + _LOGGER.debug("Starting update for zone %s", zone) + + headers = cfupdate.set_header(email, key) + _LOGGER.debug("Header data defined as: %s", headers) + + zoneid = cfupdate.get_zoneID(headers, zone) + _LOGGER.debug("Zone ID is set to: %s", zoneid) + + update_records = cfupdate.get_recordInfo(headers, zoneid, zone, records) + _LOGGER.debug("Records: %s", update_records) + + result = cfupdate.update_records(headers, zoneid, update_records) + _LOGGER.debug("Update for zone %s is complete", zone) + + if result is not True: + _LOGGER.warning(result) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index b907d4b4217..581d8fc3f7b 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -49,6 +49,10 @@ async def async_setup(hass, config): tasks = [setup_panel(panel_name) for panel_name in SECTIONS] + if hass.auth.active: + tasks.append(setup_panel('auth')) + tasks.append(setup_panel('auth_provider_homeassistant')) + for panel_name in ON_DEMAND: if panel_name in hass.config.components: tasks.append(setup_panel(panel_name)) diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py new file mode 100644 index 00000000000..6f00b03dedb --- /dev/null +++ b/homeassistant/components/config/auth.py @@ -0,0 +1,113 @@ +"""Offer API to configure Home Assistant auth.""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components import websocket_api + + +WS_TYPE_LIST = 'config/auth/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + +WS_TYPE_DELETE = 'config/auth/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('user_id'): str, +}) + +WS_TYPE_CREATE = 'config/auth/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('name'): str, +}) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list, + SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, + SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, + SCHEMA_WS_CREATE + ) + return True + + +@callback +@websocket_api.require_owner +def websocket_list(hass, connection, msg): + """Return a list of users.""" + async def send_users(): + """Send users.""" + result = [_user_info(u) for u in await hass.auth.async_get_users()] + + connection.send_message_outside( + websocket_api.result_message(msg['id'], result)) + + hass.async_add_job(send_users()) + + +@callback +@websocket_api.require_owner +def websocket_delete(hass, connection, msg): + """Delete a user.""" + async def delete_user(): + """Delete user.""" + if msg['user_id'] == connection.request.get('hass_user').id: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'no_delete_self', + 'Unable to delete your own account')) + return + + user = await hass.auth.async_get_user(msg['user_id']) + + if not user: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'not_found', 'User not found')) + return + + await hass.auth.async_remove_user(user) + + connection.send_message_outside( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(delete_user()) + + +@callback +@websocket_api.require_owner +def websocket_create(hass, connection, msg): + """Create a user.""" + async def create_user(): + """Create a user.""" + user = await hass.auth.async_create_user(msg['name']) + + connection.send_message_outside( + websocket_api.result_message(msg['id'], { + 'user': _user_info(user) + })) + + hass.async_add_job(create_user()) + + +def _user_info(user): + """Format a user.""" + return { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'system_generated': user.system_generated, + 'credentials': [ + { + 'type': c.auth_provider_type, + } for c in user.credentials + ] + } diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py new file mode 100644 index 00000000000..960e8f5e7b4 --- /dev/null +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -0,0 +1,174 @@ +"""Offer API to configure the Home Assistant auth provider.""" +import voluptuous as vol + +from homeassistant.auth.providers import homeassistant as auth_ha +from homeassistant.core import callback +from homeassistant.components import websocket_api + + +WS_TYPE_CREATE = 'config/auth_provider/homeassistant/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('user_id'): str, + vol.Required('username'): str, + vol.Required('password'): str, +}) + +WS_TYPE_DELETE = 'config/auth_provider/homeassistant/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('username'): str, +}) + +WS_TYPE_CHANGE_PASSWORD = 'config/auth_provider/homeassistant/change_password' +SCHEMA_WS_CHANGE_PASSWORD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CHANGE_PASSWORD, + vol.Required('current_password'): str, + vol.Required('new_password'): str +}) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, + SCHEMA_WS_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, + SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CHANGE_PASSWORD, websocket_change_password, + SCHEMA_WS_CHANGE_PASSWORD + ) + return True + + +def _get_provider(hass): + """Get homeassistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == 'homeassistant': + return prv + + raise RuntimeError('Provider not found') + + +@callback +@websocket_api.require_owner +def websocket_create(hass, connection, msg): + """Create credentials and attach to a user.""" + async def create_creds(): + """Create credentials.""" + provider = _get_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_get_user(msg['user_id']) + + if user is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'not_found', 'User not found')) + return + + if user.system_generated: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'system_generated', + 'Cannot add credentials to a system generated user.')) + return + + try: + await hass.async_add_executor_job( + provider.data.add_auth, msg['username'], msg['password']) + except auth_ha.InvalidUser: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'username_exists', 'Username already exists')) + return + + credentials = await provider.async_get_or_create_credentials({ + 'username': msg['username'] + }) + await hass.auth.async_link_user(user, credentials) + + await provider.data.async_save() + connection.to_write.put_nowait(websocket_api.result_message(msg['id'])) + + hass.async_add_job(create_creds()) + + +@callback +@websocket_api.require_owner +def websocket_delete(hass, connection, msg): + """Delete username and related credential.""" + async def delete_creds(): + """Delete user credentials.""" + provider = _get_provider(hass) + await provider.async_initialize() + + credentials = await provider.async_get_or_create_credentials({ + 'username': msg['username'] + }) + + # if not new, an existing credential exists. + # Removing the credential will also remove the auth. + if not credentials.is_new: + await hass.auth.async_remove_credentials(credentials) + + connection.to_write.put_nowait( + websocket_api.result_message(msg['id'])) + return + + try: + provider.data.async_remove_auth(msg['username']) + await provider.data.async_save() + except auth_ha.InvalidUser: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'auth_not_found', 'Given username was not found.')) + return + + connection.to_write.put_nowait( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(delete_creds()) + + +@callback +def websocket_change_password(hass, connection, msg): + """Change user password.""" + async def change_password(): + """Change user password.""" + user = connection.request.get('hass_user') + if user is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'user_not_found', 'User not found')) + return + + provider = _get_provider(hass) + await provider.async_initialize() + + username = None + for credential in user.credentials: + if credential.auth_provider_type == provider.type: + username = credential.data['username'] + break + + if username is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'credentials_not_found', 'Credentials not found')) + return + + try: + await provider.async_validate_login( + username, msg['current_password']) + except auth_ha.InvalidAuth: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'invalid_password', 'Invalid password')) + return + + await hass.async_add_executor_job( + provider.data.change_password, username, msg['new_password']) + await provider.data.async_save() + + connection.send_message_outside( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(change_password()) diff --git a/homeassistant/components/cover/group.py b/homeassistant/components/cover/group.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 1ed502e0f7f..87821b802ba 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -4,7 +4,6 @@ Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ -import asyncio import logging from homeassistant.components.cover import ( @@ -18,8 +17,8 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['lutron_caseta'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -49,25 +48,21 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): """Return the current position of cover.""" return self._state['current_state'] - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] self._smartbridge.set_value(self._device_id, position) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 235ff5799cc..62e1069e18b 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -4,7 +4,6 @@ Support for MQTT cover devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mqtt/ """ -import asyncio import logging import voluptuous as vol @@ -93,8 +92,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the MQTT Cover.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -174,10 +173,9 @@ class MqttCover(MqttAvailability, CoverDevice): self._position_topic = position_topic self._set_position_template = set_position_template - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() @callback def tilt_updated(topic, payload, qos): @@ -218,7 +216,7 @@ class MqttCover(MqttAvailability, CoverDevice): # Force into optimistic mode. self._optimistic = True else: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._state_topic, state_message_received, self._qos) @@ -227,7 +225,7 @@ class MqttCover(MqttAvailability, CoverDevice): else: self._tilt_optimistic = False self._tilt_value = STATE_UNKNOWN - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._tilt_status_topic, tilt_updated, self._qos) @property @@ -278,8 +276,7 @@ class MqttCover(MqttAvailability, CoverDevice): return supported_features - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up. This method is a coroutine. @@ -292,8 +289,7 @@ class MqttCover(MqttAvailability, CoverDevice): self._state = False self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down. This method is a coroutine. @@ -306,8 +302,7 @@ class MqttCover(MqttAvailability, CoverDevice): self._state = True self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the device. This method is a coroutine. @@ -316,8 +311,7 @@ class MqttCover(MqttAvailability, CoverDevice): self.hass, self._command_topic, self._payload_stop, self._qos, self._retain) - @asyncio.coroutine - def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" mqtt.async_publish(self.hass, self._tilt_command_topic, self._tilt_open_position, self._qos, @@ -326,8 +320,7 @@ class MqttCover(MqttAvailability, CoverDevice): self._tilt_value = self._tilt_open_position self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" mqtt.async_publish(self.hass, self._tilt_command_topic, self._tilt_closed_position, self._qos, @@ -336,8 +329,7 @@ class MqttCover(MqttAvailability, CoverDevice): self._tilt_value = self._tilt_closed_position self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION not in kwargs: return @@ -350,8 +342,7 @@ class MqttCover(MqttAvailability, CoverDevice): mqtt.async_publish(self.hass, self._tilt_command_topic, level, self._qos, self._retain) - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] diff --git a/homeassistant/components/cover/rflink.py b/homeassistant/components/cover/rflink.py index a9b7598159f..3357bf2d204 100644 --- a/homeassistant/components/cover/rflink.py +++ b/homeassistant/components/cover/rflink.py @@ -4,7 +4,6 @@ Support for Rflink Cover devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.rflink/ """ -import asyncio import logging import voluptuous as vol @@ -79,8 +78,8 @@ def devices_from_config(domain_config, hass=None): return devices -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Rflink cover platform.""" async_add_devices(devices_from_config(config, hass)) diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index cf8b7dfad48..824e330d6a0 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -81,7 +81,11 @@ class TahomaCover(TahomaDevice, CoverDevice): self.apply_action('setPosition', 'secured') elif self.tahoma_device.type in \ ('rts:BlindRTSComponent', - 'io:ExteriorVenetianBlindIOComponent'): + 'io:ExteriorVenetianBlindIOComponent', + 'rts:VenetianBlindRTSComponent', + 'rts:DualCurtainRTSComponent', + 'rts:ExteriorVenetianBlindRTSComponent', + 'rts:BlindRTSComponent'): self.apply_action('my') else: self.apply_action('stopIdentify') diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 4e197365a70..d9d0d61c77a 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -4,7 +4,6 @@ Support for covers which integrate with other components. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.template/ """ -import asyncio import logging import voluptuous as vol @@ -72,8 +71,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Template cover.""" covers = [] @@ -199,8 +198,7 @@ class CoverTemplate(CoverDevice): if self._entity_picture_template is not None: self._entity_picture_template.hass = self.hass - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Register callbacks.""" @callback def template_cover_state_listener(entity, old_state, new_state): @@ -277,70 +275,62 @@ class CoverTemplate(CoverDevice): """Return the polling state.""" return False - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up.""" if self._open_script: - yield from self._open_script.async_run() + await self._open_script.async_run() elif self._position_script: - yield from self._position_script.async_run({"position": 100}) + await self._position_script.async_run({"position": 100}) if self._optimistic: self._position = 100 self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down.""" if self._close_script: - yield from self._close_script.async_run() + await self._close_script.async_run() elif self._position_script: - yield from self._position_script.async_run({"position": 0}) + await self._position_script.async_run({"position": 0}) if self._optimistic: self._position = 0 self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Fire the stop action.""" if self._stop_script: - yield from self._stop_script.async_run() + await self._stop_script.async_run() - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Set cover position.""" self._position = kwargs[ATTR_POSITION] - yield from self._position_script.async_run( + await self._position_script.async_run( {"position": self._position}) if self._optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" self._tilt_value = 100 - yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + await self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" self._tilt_value = 0 - yield from self._tilt_script.async_run( + await self._tilt_script.async_run( {"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] - yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + await self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update the state from the template.""" if self._template is not None: try: diff --git a/homeassistant/components/cover/velbus.py b/homeassistant/components/cover/velbus.py index ab5d6e8ef79..fd060e7a7e1 100644 --- a/homeassistant/components/cover/velbus.py +++ b/homeassistant/components/cover/velbus.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.velbus/ """ import logging -import asyncio import time import voluptuous as vol @@ -70,15 +69,14 @@ class VelbusCover(CoverDevice): self._open_channel = open_channel self._close_channel = close_channel - @asyncio.coroutine - def async_added_to_hass(self): + async 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) + await self.hass.async_add_job(_init_velbus) def _on_message(self, message): import velbus diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 7f7a3a11644..2206de05041 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -4,8 +4,6 @@ Support for Wink Covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.wink/ """ -import asyncio - from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN, \ ATTR_POSITION from homeassistant.components.wink import WinkDevice, DOMAIN @@ -34,8 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class WinkCoverDevice(WinkDevice, CoverDevice): """Representation of a Wink cover device.""" - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['cover'].append(self) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 4fa89f8cfd3..88174b9d612 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -22,7 +22,7 @@ from .const import ( CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DATA_DECONZ_EVENT, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) -REQUIREMENTS = ['pydeconz==39'] +REQUIREMENTS = ['pydeconz==42'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index f7aa4c7a430..6deee322a15 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -11,3 +11,6 @@ DATA_DECONZ_UNSUB = 'deconz_dispatchers' CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' + +ATTR_DARK = 'dark' +ATTR_ON = 'on' diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index 92ef78f60f3..61eee99e721 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _DEVICES_REGEX = re.compile( r'(?P([^\s]+)?)\s+' + diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 5cb7e283c99..710a07f77d3 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, CONF_PROTOCOL) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) @@ -311,12 +311,11 @@ class SshConnection(_Connection): super().connect() - def disconnect(self): \ - # pylint: disable=broad-except + def disconnect(self): """Disconnect the current SSH connection.""" try: self._ssh.logout() - except Exception: + except Exception: # pylint: disable=broad-except pass finally: self._ssh = None @@ -379,12 +378,11 @@ class TelnetConnection(_Connection): super().connect() - def disconnect(self): \ - # pylint: disable=broad-except + def disconnect(self): """Disconnect the current Telnet connection.""" try: self._telnet.write('exit\n'.encode('ascii')) - except Exception: + except Exception: # pylint: disable=broad-except pass super().disconnect() diff --git a/homeassistant/components/device_tracker/cisco_ios.py b/homeassistant/components/device_tracker/cisco_ios.py index c13f622c5bf..1afea2c1607 100644 --- a/homeassistant/components/device_tracker/cisco_ios.py +++ b/homeassistant/components/device_tracker/cisco_ios.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, \ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index e9a7efeb64a..dfc66a412c3 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -66,7 +66,6 @@ class MikrotikScanner(DeviceScanner): def connect_to_device(self): """Connect to Mikrotik method.""" - # pylint: disable=import-error import librouteros try: self.client = librouteros.connect( diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index 377686b6905..6df9f3c9974 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -5,24 +5,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.tile/ """ import logging +from datetime import timedelta import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.const import ( CONF_USERNAME, CONF_MONITORED_VARIABLES, CONF_PASSWORD) -from homeassistant.helpers.event import track_utc_time_change +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import slugify from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['pytile==1.1.0'] +REQUIREMENTS = ['pytile==2.0.2'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' -DEFAULT_ICON = 'mdi:bluetooth' DEVICE_TYPES = ['PHONE', 'TILE'] ATTR_ALTITUDE = 'altitude' @@ -34,89 +32,111 @@ ATTR_VOIP_STATE = 'voip_state' CONF_SHOW_INACTIVE = 'show_inactive' +DEFAULT_ICON = 'mdi:bluetooth' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=2) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_SHOW_INACTIVE, default=False): cv.boolean, - vol.Optional(CONF_MONITORED_VARIABLES): + vol.Optional(CONF_MONITORED_VARIABLES, default=DEVICE_TYPES): vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), }) -def setup_scanner(hass, config: dict, see, discovery_info=None): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Validate the configuration and return a Tile scanner.""" - TileDeviceScanner(hass, config, see) - return True + from pytile import Client + + websession = aiohttp_client.async_get_clientsession(hass) + + config_data = await hass.async_add_job( + load_json, hass.config.path(CLIENT_UUID_CONFIG_FILE)) + if config_data: + client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD], + websession, + client_uuid=config_data['client_uuid']) + else: + client = Client( + config[CONF_USERNAME], config[CONF_PASSWORD], websession) + + config_data = {'client_uuid': client.client_uuid} + config_saved = await hass.async_add_job( + save_json, hass.config.path(CLIENT_UUID_CONFIG_FILE), config_data) + if not config_saved: + _LOGGER.error('Failed to save the client UUID') + + scanner = TileScanner( + client, hass, async_see, config[CONF_MONITORED_VARIABLES], + config[CONF_SHOW_INACTIVE]) + return await scanner.async_init() -class TileDeviceScanner(DeviceScanner): - """Define a device scanner for Tiles.""" +class TileScanner(object): + """Define an object to retrieve Tile data.""" - def __init__(self, hass, config, see): + def __init__(self, client, hass, async_see, types, show_inactive): """Initialize.""" - from pytile import Client + self._async_see = async_see + self._client = client + self._hass = hass + self._show_inactive = show_inactive + self._types = types - _LOGGER.debug('Received configuration data: %s', config) + async def async_init(self): + """Further initialize connection to the Tile servers.""" + from pytile.errors import TileError - # Load the client UUID (if it exists): - config_data = load_json(hass.config.path(CLIENT_UUID_CONFIG_FILE)) - if config_data: - _LOGGER.debug('Using existing client UUID') - self._client = Client( - config[CONF_USERNAME], - config[CONF_PASSWORD], - config_data['client_uuid']) - else: - _LOGGER.debug('Generating new client UUID') - self._client = Client( - config[CONF_USERNAME], - config[CONF_PASSWORD]) + try: + await self._client.async_init() + except TileError as err: + _LOGGER.error('Unable to set up Tile scanner: %s', err) + return False - if not save_json( - hass.config.path(CLIENT_UUID_CONFIG_FILE), - {'client_uuid': self._client.client_uuid}): - _LOGGER.error("Failed to save configuration file") + await self._async_update() - _LOGGER.debug('Client UUID: %s', self._client.client_uuid) - _LOGGER.debug('User UUID: %s', self._client.user_uuid) + async_track_time_interval( + self._hass, self._async_update, DEFAULT_SCAN_INTERVAL) - self._show_inactive = config.get(CONF_SHOW_INACTIVE) - self._types = config.get(CONF_MONITORED_VARIABLES) + return True - self.devices = {} - self.see = see + async def _async_update(self, now=None): + """Update info from Tile.""" + from pytile.errors import SessionExpiredError, TileError - track_utc_time_change( - hass, self._update_info, second=range(0, 60, 30)) + _LOGGER.debug('Updating Tile data') - self._update_info() + try: + await self._client.asayn_init() + tiles = await self._client.tiles.all( + whitelist=self._types, show_inactive=self._show_inactive) + except SessionExpiredError: + _LOGGER.info('Session expired; trying again shortly') + return + except TileError as err: + _LOGGER.error('There was an error while updating: %s', err) + return - def _update_info(self, now=None) -> None: - """Update the device info.""" - self.devices = self._client.get_tiles( - type_whitelist=self._types, show_inactive=self._show_inactive) - - if not self.devices: + if not tiles: _LOGGER.warning('No Tiles found') return - for dev in self.devices: - dev_id = 'tile_{0}'.format(slugify(dev['name'])) - lat = dev['tileState']['latitude'] - lon = dev['tileState']['longitude'] - - attrs = { - ATTR_ALTITUDE: dev['tileState']['altitude'], - ATTR_CONNECTION_STATE: dev['tileState']['connection_state'], - ATTR_IS_DEAD: dev['is_dead'], - ATTR_IS_LOST: dev['tileState']['is_lost'], - ATTR_RING_STATE: dev['tileState']['ring_state'], - ATTR_VOIP_STATE: dev['tileState']['voip_state'], - } - - self.see( - dev_id=dev_id, - gps=(lat, lon), - attributes=attrs, - icon=DEFAULT_ICON - ) + for tile in tiles: + await self._async_see( + dev_id='tile_{0}'.format(slugify(tile['name'])), + gps=( + tile['tileState']['latitude'], + tile['tileState']['longitude'] + ), + attributes={ + ATTR_ALTITUDE: tile['tileState']['altitude'], + ATTR_CONNECTION_STATE: + tile['tileState']['connection_state'], + ATTR_IS_DEAD: tile['is_dead'], + ATTR_IS_LOST: tile['tileState']['is_lost'], + ATTR_RING_STATE: tile['tileState']['ring_state'], + ATTR_VOIP_STATE: tile['tileState']['voip_state'], + }, + icon=DEFAULT_ICON) diff --git a/homeassistant/components/device_tracker/unifi_direct.py b/homeassistant/components/device_tracker/unifi_direct.py index c3c4a48bb82..228443fe22b 100644 --- a/homeassistant/components/device_tracker/unifi_direct.py +++ b/homeassistant/components/device_tracker/unifi_direct.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index 5d6e1453124..074d6a1054e 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -64,7 +64,7 @@ class XiaomiMiioDeviceScanner(DeviceScanner): station_info = await self.hass.async_add_job(self.device.status) _LOGGER.debug("Got new station info: %s", station_info) - for device in station_info['mat']: + for device in station_info.associated_stations: devices.append(device['mac']) except DeviceException as ex: diff --git a/homeassistant/components/dialogflow.py b/homeassistant/components/dialogflow.py index 7a0918aab25..28b3a05e403 100644 --- a/homeassistant/components/dialogflow.py +++ b/homeassistant/components/dialogflow.py @@ -99,7 +99,8 @@ async def async_handle_message(hass, message): return None action = req.get('action', '') - parameters = req.get('parameters') + parameters = req.get('parameters').copy() + parameters["dialogflow_query"] = message dialogflow_response = DialogflowResponse(parameters) if action == "": diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 6988e20fb5f..36ce1c392f9 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/emulated_hue/ """ import logging +from aiohttp import web import voluptuous as vol from homeassistant import util @@ -13,7 +14,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.components.http import REQUIREMENTS # NOQA -from homeassistant.components.http import HomeAssistantHTTP from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv @@ -85,28 +85,17 @@ def setup(hass, yaml_config): """Activate the emulated_hue component.""" config = Config(hass, yaml_config.get(DOMAIN, {})) - server = HomeAssistantHTTP( - hass, - server_host=config.host_ip_addr, - server_port=config.listen_port, - api_password=None, - ssl_certificate=None, - ssl_peer_certificate=None, - ssl_key=None, - cors_origins=None, - use_x_forwarded_for=False, - trusted_proxies=[], - trusted_networks=[], - login_threshold=0, - is_ban_enabled=False - ) + app = web.Application() + app['hass'] = hass + handler = None + server = None - server.register_view(DescriptionXmlView(config)) - server.register_view(HueUsernameView) - server.register_view(HueAllLightsStateView(config)) - server.register_view(HueOneLightStateView(config)) - server.register_view(HueOneLightChangeView(config)) - server.register_view(HueGroupView(config)) + DescriptionXmlView(config).register(app, app.router) + HueUsernameView().register(app, app.router) + HueAllLightsStateView(config).register(app, app.router) + HueOneLightStateView(config).register(app, app.router) + HueOneLightChangeView(config).register(app, app.router) + HueGroupView(config).register(app, app.router) upnp_listener = UPNPResponderThread( config.host_ip_addr, config.listen_port, @@ -116,14 +105,31 @@ def setup(hass, yaml_config): async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - await server.stop() + if server: + server.close() + await server.wait_closed() + await app.shutdown() + if handler: + await handler.shutdown(10) + await app.cleanup() async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - await server.start() - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) + nonlocal handler + nonlocal server + + handler = app.make_handler(loop=hass.loop) + + try: + server = await hass.loop.create_server( + handler, config.host_ip_addr, config.listen_port) + except OSError as error: + _LOGGER.error("Failed to create HTTP server at port %d: %s", + config.listen_port, error) + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 879f6a61899..75e456f62bd 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -75,6 +75,7 @@ class EnOceanDongle: _LOGGER.debug("Received radio packet: %s", temp) rxtype = None value = None + channel = 0 if temp.data[6] == 0x30: rxtype = "wallswitch" value = 1 @@ -84,8 +85,9 @@ class EnOceanDongle: elif temp.data[4] == 0x0c: rxtype = "power" value = temp.data[3] + (temp.data[2] << 8) - elif temp.data[2] == 0x60: + elif temp.data[2] & 0x60 == 0x60: rxtype = "switch_status" + channel = temp.data[2] & 0x1F if temp.data[3] == 0xe4: value = 1 elif temp.data[3] == 0x80: @@ -104,7 +106,8 @@ class EnOceanDongle: if temp.sender_int == self._combine_hex(device.dev_id): if value > 10: device.value_changed(1) - if rxtype == "switch_status" and device.stype == "switch": + if rxtype == "switch_status" and device.stype == "switch" and \ + channel == device.channel: if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "dimmerstatus" and device.stype == "dimmer": diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py index e86e7348d58..69d4905228a 100644 --- a/homeassistant/components/eufy.py +++ b/homeassistant/components/eufy.py @@ -49,7 +49,6 @@ EUFY_DISPATCH = { def setup(hass, config): """Set up Eufy devices.""" - # pylint: disable=import-error import lakeside if CONF_USERNAME in config[DOMAIN] and CONF_PASSWORD in config[DOMAIN]: diff --git a/homeassistant/components/fritzbox.py b/homeassistant/components/fritzbox.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ca886ec25f8..68e88406ad6 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,10 +26,10 @@ 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==20180708.0'] +REQUIREMENTS = ['home-assistant-frontend==20180720.0'] DOMAIN = 'frontend' -DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] +DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', 'onboarding'] CONF_THEMES = 'themes' CONF_EXTRA_HTML_URL = 'extra_html_url' @@ -50,7 +50,7 @@ MANIFEST_JSON = { 'lang': 'en-US', 'name': 'Home Assistant', 'short_name': 'Assistant', - 'start_url': '/states', + 'start_url': '/?homescreen=1', 'theme_color': DEFAULT_THEME_COLOR } @@ -200,15 +200,6 @@ def add_manifest_json_key(key, val): async def async_setup(hass, config): """Set up the serving of the frontend.""" - if hass.auth.active: - client = await hass.auth.async_get_or_create_client( - 'Home Assistant Frontend', - redirect_uris=['/'], - no_secret=True, - ) - else: - client = None - hass.components.websocket_api.async_register_command( WS_TYPE_GET_PANELS, websocket_get_panels, SCHEMA_GET_PANELS) hass.components.websocket_api.async_register_command( @@ -255,7 +246,7 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path, js_version, client) + index_view = IndexView(repo_path, js_version, hass.auth.active) hass.http.register_view(index_view) @callback @@ -266,7 +257,7 @@ async def async_setup(hass, config): await asyncio.wait( [async_register_built_in_panel(hass, panel) for panel in ( 'dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace')], + 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace', 'profile')], loop=hass.loop) hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel @@ -350,11 +341,11 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self, repo_path, js_option, client): + def __init__(self, repo_path, js_option, auth_active): """Initialize the frontend view.""" self.repo_path = repo_path self.js_option = js_option - self.client = client + self.auth_active = auth_active self._template_cache = {} def get_template(self, latest): @@ -386,11 +377,23 @@ class IndexView(HomeAssistantView): latest = self.repo_path is not None or \ _is_latest(self.js_option, request) + if not hass.components.onboarding.async_is_onboarded(): + if latest: + location = '/frontend_latest/onboarding.html' + else: + location = '/frontend_es5/onboarding.html' + + return web.Response(status=302, headers={ + 'location': location + }) + no_auth = '1' if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: # do not try to auto connect on load no_auth = '0' + use_oauth = '1' if self.auth_active else '0' + template = await hass.async_add_job(self.get_template, latest) extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 @@ -399,11 +402,9 @@ class IndexView(HomeAssistantView): no_auth=no_auth, theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[extra_key], + use_oauth=use_oauth ) - if self.client is not None: - template_params['client_id'] = self.client.id - return web.Response(text=template.render(**template_params), content_type='text/html') @@ -489,7 +490,7 @@ def websocket_get_translations(hass, connection, msg): Async friendly. """ async def send_translations(): - """Send a camera still.""" + """Send a translation.""" resources = await async_get_translations(hass, msg['language']) connection.send_message_outside(websocket_api.result_message( msg['id'], { diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index 203b1a94b7f..fdbc3382072 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -25,6 +25,7 @@ from homeassistant.util import convert, dt REQUIREMENTS = [ 'google-api-python-client==1.6.4', + 'httplib2==0.10.3', 'oauth2client==4.0.0', ] diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 65079a1a26e..05bc3cbd01c 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -10,10 +10,8 @@ from aiohttp.hdrs import AUTHORIZATION from aiohttp.web import Request, Response # Typing imports -# pylint: disable=unused-import from homeassistant.components.http import HomeAssistantView -from homeassistant.core import HomeAssistant, callback # NOQA -from homeassistant.helpers.entity import Entity # NOQA +from homeassistant.core import callback from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index f20d4f747cc..927139a483e 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -3,14 +3,6 @@ import collections from itertools import product import logging -# Typing imports -# pylint: disable=unused-import -# if False: -from aiohttp.web import Request, Response # NOQA -from typing import Dict, Tuple, Any, Optional # NOQA -from homeassistant.helpers.entity import Entity # NOQA -from homeassistant.core import HomeAssistant # NOQA -from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.util.decorator import Registry from homeassistant.core import callback diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 34fdcb2c035..5e24fe82340 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -14,7 +14,7 @@ from homeassistant.components.discovery import SERVICE_HOMEKIT from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['homekit==0.6'] +REQUIREMENTS = ['homekit==0.10'] DOMAIN = 'homekit_controller' HOMEKIT_DIR = '.homekit' @@ -26,6 +26,12 @@ HOMEKIT_ACCESSORY_DISPATCH = { 'thermostat': 'climate', } +HOMEKIT_IGNORE = [ + 'BSB002', + 'Home Assistant Bridge', + 'TRADFRI gateway' +] + KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) KNOWN_DEVICES = "{}-devices".format(DOMAIN) @@ -237,6 +243,9 @@ def setup(hass, config): hkid = discovery_info['properties']['id'] config_num = int(discovery_info['properties']['c#']) + if model in HOMEKIT_IGNORE: + return + # Only register a device once, but rescan if the config has changed if hkid in hass.data[KNOWN_DEVICES]: device = hass.data[KNOWN_DEVICES][hkid] diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 1428bbd3e56..6754db05f77 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.44'] +REQUIREMENTS = ['pyhomematic==0.1.45'] _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,7 @@ HM_DEVICE_TYPES = { 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', - 'IPWeatherSensor', 'RotaryHandleSensorIP'], + 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -80,7 +80,7 @@ HM_DEVICE_TYPES = { 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', - 'WiredSensor', 'PresenceIP', 'IPWeatherSensor'], + 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } @@ -114,7 +114,7 @@ HM_ATTRIBUTE_SUPPORT = { 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], 'OPERATING_VOLTAGE': ['voltage', {}], - 'WORKING': ['working', {0: 'No', 1: 'Yes'}], + 'WORKING': ['working', {0: 'No', 1: 'Yes'}] } HM_PRESS_EVENTS = [ diff --git a/homeassistant/components/homematicip_cloud.py b/homeassistant/components/homematicip_cloud.py deleted file mode 100644 index 859841dfca6..00000000000 --- a/homeassistant/components/homematicip_cloud.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -Support for HomematicIP components. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/homematicip_cloud/ -""" - -import asyncio -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.entity import Entity -from homeassistant.core import callback - -REQUIREMENTS = ['homematicip==0.9.4'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'homematicip_cloud' - -COMPONENTS = [ - 'sensor', - 'binary_sensor', - 'switch', - 'light', - 'climate', -] - -CONF_NAME = 'name' -CONF_ACCESSPOINT = 'accesspoint' -CONF_AUTHTOKEN = 'authtoken' - -CONFIG_SCHEMA = vol.Schema({ - vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({ - vol.Optional(CONF_NAME): vol.Any(cv.string), - vol.Required(CONF_ACCESSPOINT): cv.string, - vol.Required(CONF_AUTHTOKEN): cv.string, - })]), -}, extra=vol.ALLOW_EXTRA) - -HMIP_ACCESS_POINT = 'Access Point' -HMIP_HUB = 'HmIP-HUB' - -ATTR_HOME_ID = 'home_id' -ATTR_HOME_NAME = 'home_name' -ATTR_DEVICE_ID = 'device_id' -ATTR_DEVICE_LABEL = 'device_label' -ATTR_STATUS_UPDATE = 'status_update' -ATTR_FIRMWARE_STATE = 'firmware_state' -ATTR_UNREACHABLE = 'unreachable' -ATTR_LOW_BATTERY = 'low_battery' -ATTR_MODEL_TYPE = 'model_type' -ATTR_GROUP_TYPE = 'group_type' -ATTR_DEVICE_RSSI = 'device_rssi' -ATTR_DUTY_CYCLE = 'duty_cycle' -ATTR_CONNECTED = 'connected' -ATTR_SABOTAGE = 'sabotage' -ATTR_OPERATION_LOCK = 'operation_lock' - - -async def async_setup(hass, config): - """Set up the HomematicIP component.""" - from homematicip.base.base_connection import HmipConnectionError - - hass.data.setdefault(DOMAIN, {}) - accesspoints = config.get(DOMAIN, []) - for conf in accesspoints: - _websession = async_get_clientsession(hass) - _hmip = HomematicipConnector(hass, conf, _websession) - try: - await _hmip.init() - except HmipConnectionError: - _LOGGER.error('Failed to connect to the HomematicIP server, %s.', - conf.get(CONF_ACCESSPOINT)) - return False - - home = _hmip.home - home.name = conf.get(CONF_NAME) - home.label = HMIP_ACCESS_POINT - home.modelType = HMIP_HUB - - hass.data[DOMAIN][home.id] = home - _LOGGER.info('Connected to the HomematicIP server, %s.', - conf.get(CONF_ACCESSPOINT)) - homeid = {ATTR_HOME_ID: home.id} - for component in COMPONENTS: - hass.async_add_job(async_load_platform(hass, component, DOMAIN, - homeid, config)) - - hass.loop.create_task(_hmip.connect()) - return True - - -class HomematicipConnector: - """Manages HomematicIP http and websocket connection.""" - - def __init__(self, hass, config, websession): - """Initialize HomematicIP cloud connection.""" - from homematicip.async.home import AsyncHome - - self._hass = hass - self._ws_close_requested = False - self._retry_task = None - self._tries = 0 - self._accesspoint = config.get(CONF_ACCESSPOINT) - _authtoken = config.get(CONF_AUTHTOKEN) - - self.home = AsyncHome(hass.loop, websession) - self.home.set_auth_token(_authtoken) - - self.home.on_update(self.async_update) - self._accesspoint_connected = True - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.close()) - - async def init(self): - """Initialize connection.""" - await self.home.init(self._accesspoint) - await self.home.get_current_state() - - @callback - def async_update(self, *args, **kwargs): - """Async update the home device. - - Triggered when the hmip HOME_CHANGED event has fired. - There are several occasions for this event to happen. - We are only interested to check whether the access point - is still connected. If not, device state changes cannot - be forwarded to hass. So if access point is disconnected all devices - are set to unavailable. - """ - if not self.home.connected: - _LOGGER.error( - "HMIP access point has lost connection with the cloud") - self._accesspoint_connected = False - self.set_all_to_unavailable() - elif not self._accesspoint_connected: - # Explicitly getting an update as device states might have - # changed during access point disconnect.""" - - job = self._hass.async_add_job(self.get_state()) - job.add_done_callback(self.get_state_finished) - - async def get_state(self): - """Update hmip state and tell hass.""" - await self.home.get_current_state() - self.update_all() - - def get_state_finished(self, future): - """Execute when get_state coroutine has finished.""" - from homematicip.base.base_connection import HmipConnectionError - - try: - future.result() - except HmipConnectionError: - # Somehow connection could not recover. Will disconnect and - # so reconnect loop is taking over. - _LOGGER.error( - "updating state after himp access point reconnect failed.") - self._hass.async_add_job(self.home.disable_events()) - - def set_all_to_unavailable(self): - """Set all devices to unavailable and tell Hass.""" - for device in self.home.devices: - device.unreach = True - self.update_all() - - def update_all(self): - """Signal all devices to update their state.""" - for device in self.home.devices: - device.fire_update_event() - - async def _handle_connection(self): - """Handle websocket connection.""" - from homematicip.base.base_connection import HmipConnectionError - - await self.home.get_current_state() - hmip_events = await self.home.enable_events() - try: - await hmip_events - except HmipConnectionError: - return - - async def connect(self): - """Start websocket connection.""" - self._tries = 0 - while True: - await self._handle_connection() - if self._ws_close_requested: - break - self._ws_close_requested = False - self._tries += 1 - try: - self._retry_task = self._hass.async_add_job(asyncio.sleep( - 2 ** min(9, self._tries), loop=self._hass.loop)) - await self._retry_task - except asyncio.CancelledError: - break - _LOGGER.info('Reconnect (%s) to the HomematicIP cloud server.', - self._tries) - - async def close(self): - """Close the websocket connection.""" - self._ws_close_requested = True - if self._retry_task is not None: - self._retry_task.cancel() - await self.home.disable_events() - _LOGGER.info("Closed connection to HomematicIP cloud server.") - - -class HomematicipGenericDevice(Entity): - """Representation of an HomematicIP generic device.""" - - def __init__(self, home, device, post=None): - """Initialize the generic device.""" - self._home = home - self._device = device - self.post = post - _LOGGER.info('Setting up %s (%s)', self.name, - self._device.modelType) - - async def async_added_to_hass(self): - """Register callbacks.""" - self._device.on_update(self._device_changed) - - def _device_changed(self, json, **kwargs): - """Handle device state changes.""" - _LOGGER.debug('Event %s (%s)', self.name, self._device.modelType) - self.async_schedule_update_ha_state() - - @property - def name(self): - """Return the name of the generic device.""" - name = self._device.label - if self._home.name is not None: - name = "{} {}".format(self._home.name, name) - if self.post is not None: - name = "{} {}".format(name, self.post) - return name - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def available(self): - """Device available.""" - return not self._device.unreach - - @property - def device_state_attributes(self): - """Return the state attributes of the generic device.""" - return { - ATTR_LOW_BATTERY: self._device.lowBat, - ATTR_MODEL_TYPE: self._device.modelType - } diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json new file mode 100644 index 00000000000..887a3a5780b --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "HomematicIP Cloud", + "step": { + "init": { + "title": "Pick HomematicIP Accesspoint", + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "pin": "Pin Code (optional)", + "name": "Name (optional, used as name prefix for all devices)" + } + }, + "link": { + "title": "Link Accesspoint", + "description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + } + }, + "error": { + "register_failed": "Failed to register, please try again.", + "invalid_pin": "Invalid PIN, please try again.", + "press_the_button": "Please press the blue button.", + "timeout_button": "Blue button press timeout, please try again." + }, + "abort": { + "unknown": "Unknown error occurred.", + "conection_aborted": "Could not connect to HMIP server", + "already_configured": "Accesspoint is already configured" + } + } +} diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py new file mode 100644 index 00000000000..b9266322978 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -0,0 +1,65 @@ +""" +Support for HomematicIP components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/homematicip_cloud/ +""" + +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +from .const import ( + DOMAIN, HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_NAME, + CONF_ACCESSPOINT, CONF_AUTHTOKEN, CONF_NAME) +# Loading the config flow file will register the flow +from .config_flow import configured_haps +from .hap import HomematicipHAP, HomematicipAuth # noqa: F401 +from .device import HomematicipGenericDevice # noqa: F401 + +REQUIREMENTS = ['homematicip==0.9.8'] + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({ + vol.Optional(CONF_NAME, default=''): vol.Any(cv.string), + vol.Required(CONF_ACCESSPOINT): cv.string, + vol.Required(CONF_AUTHTOKEN): cv.string, + })]), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the HomematicIP component.""" + hass.data[DOMAIN] = {} + + accesspoints = config.get(DOMAIN, []) + + 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={ + HMIPC_HAPID: conf[CONF_ACCESSPOINT], + HMIPC_AUTHTOKEN: conf[CONF_AUTHTOKEN], + HMIPC_NAME: conf[CONF_NAME], + } + )) + + return True + + +async def async_setup_entry(hass, entry): + """Set up an accsspoint from a config entry.""" + hap = HomematicipHAP(hass, entry) + hapid = entry.data[HMIPC_HAPID].replace('-', '').upper() + hass.data[DOMAIN][hapid] = hap + return await hap.async_setup() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hap = hass.data[DOMAIN].pop(entry.data[HMIPC_HAPID]) + return await hap.async_reset() diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py new file mode 100644 index 00000000000..9e5356d914a --- /dev/null +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow to configure HomematicIP Cloud.""" +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import callback + +from .const import ( + DOMAIN as HMIPC_DOMAIN, _LOGGER, + HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME) +from .hap import HomematicipAuth + + +@callback +def configured_haps(hass): + """Return a set of the configured accesspoints.""" + return set(entry.data[HMIPC_HAPID] for entry + in hass.config_entries.async_entries(HMIPC_DOMAIN)) + + +@config_entries.HANDLERS.register(HMIPC_DOMAIN) +class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler): + """Config flow HomematicIP Cloud.""" + + VERSION = 1 + + def __init__(self): + """Initialize HomematicIP Cloud config flow.""" + self.auth = None + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + errors = {} + + if user_input is not None: + user_input[HMIPC_HAPID] = \ + user_input[HMIPC_HAPID].replace('-', '').upper() + if user_input[HMIPC_HAPID] in configured_haps(self.hass): + return self.async_abort(reason='already_configured') + + self.auth = HomematicipAuth(self.hass, user_input) + connected = await self.auth.async_setup() + if connected: + _LOGGER.info("Connection established") + return await self.async_step_link() + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required(HMIPC_HAPID): str, + vol.Optional(HMIPC_PIN): str, + vol.Optional(HMIPC_NAME): str, + }), + errors=errors + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the HomematicIP Cloud accesspoint.""" + errors = {} + + pressed = await self.auth.async_checkbutton() + if pressed: + authtoken = await self.auth.async_register() + if authtoken: + _LOGGER.info("Write config entry") + return self.async_create_entry( + title=self.auth.config.get(HMIPC_HAPID), + data={ + HMIPC_HAPID: self.auth.config.get(HMIPC_HAPID), + HMIPC_AUTHTOKEN: authtoken, + HMIPC_NAME: self.auth.config.get(HMIPC_NAME) + }) + return self.async_abort(reason='conection_aborted') + else: + errors['base'] = 'press_the_button' + + return self.async_show_form(step_id='link', errors=errors) + + async def async_step_import(self, import_info): + """Import a new bridge as a config entry.""" + hapid = import_info[HMIPC_HAPID] + authtoken = import_info[HMIPC_AUTHTOKEN] + name = import_info[HMIPC_NAME] + + hapid = hapid.replace('-', '').upper() + if hapid in configured_haps(self.hass): + return self.async_abort(reason='already_configured') + + _LOGGER.info('Imported authentication for %s', hapid) + + return self.async_create_entry( + title=hapid, + data={ + HMIPC_HAPID: hapid, + HMIPC_AUTHTOKEN: authtoken, + HMIPC_NAME: name + } + ) diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py new file mode 100644 index 00000000000..54b05c464b5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/const.py @@ -0,0 +1,24 @@ +"""Constants for the HomematicIP Cloud component.""" +import logging + +_LOGGER = logging.getLogger('homeassistant.components.homematicip_cloud') + +DOMAIN = 'homematicip_cloud' + +COMPONENTS = [ + 'alarm_control_panel', + 'binary_sensor', + 'climate', + 'light', + 'sensor', + 'switch', +] + +CONF_NAME = 'name' +CONF_ACCESSPOINT = 'accesspoint' +CONF_AUTHTOKEN = 'authtoken' + +HMIPC_NAME = 'name' +HMIPC_HAPID = 'hapid' +HMIPC_AUTHTOKEN = 'authtoken' +HMIPC_PIN = 'pin' diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py new file mode 100644 index 00000000000..94fe5f40be8 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/device.py @@ -0,0 +1,71 @@ +"""GenericDevice for the HomematicIP Cloud component.""" +import logging + +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_HOME_ID = 'home_id' +ATTR_HOME_NAME = 'home_name' +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_LABEL = 'device_label' +ATTR_STATUS_UPDATE = 'status_update' +ATTR_FIRMWARE_STATE = 'firmware_state' +ATTR_UNREACHABLE = 'unreachable' +ATTR_LOW_BATTERY = 'low_battery' +ATTR_MODEL_TYPE = 'model_type' +ATTR_GROUP_TYPE = 'group_type' +ATTR_DEVICE_RSSI = 'device_rssi' +ATTR_DUTY_CYCLE = 'duty_cycle' +ATTR_CONNECTED = 'connected' +ATTR_SABOTAGE = 'sabotage' +ATTR_OPERATION_LOCK = 'operation_lock' + + +class HomematicipGenericDevice(Entity): + """Representation of an HomematicIP generic device.""" + + def __init__(self, home, device, post=None): + """Initialize the generic device.""" + self._home = home + self._device = device + self.post = post + _LOGGER.info('Setting up %s (%s)', self.name, + self._device.modelType) + + async def async_added_to_hass(self): + """Register callbacks.""" + self._device.on_update(self._device_changed) + + def _device_changed(self, json, **kwargs): + """Handle device state changes.""" + _LOGGER.debug('Event %s (%s)', self.name, self._device.modelType) + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the generic device.""" + name = self._device.label + if (self._home.name is not None and self._home.name != ''): + name = "{} {}".format(self._home.name, name) + if (self.post is not None and self.post != ''): + name = "{} {}".format(name, self.post) + return name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def available(self): + """Device available.""" + return not self._device.unreach + + @property + def device_state_attributes(self): + """Return the state attributes of the generic device.""" + return { + ATTR_LOW_BATTERY: self._device.lowBat, + ATTR_MODEL_TYPE: self._device.modelType + } diff --git a/homeassistant/components/homematicip_cloud/errors.py b/homeassistant/components/homematicip_cloud/errors.py new file mode 100644 index 00000000000..cb2925d1a70 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/errors.py @@ -0,0 +1,22 @@ +"""Errors for the HomematicIP component.""" +from homeassistant.exceptions import HomeAssistantError + + +class HmipcException(HomeAssistantError): + """Base class for HomematicIP exceptions.""" + + +class HmipcConnectionError(HmipcException): + """Unable to connect to the HomematicIP cloud server.""" + + +class HmipcConnectionWait(HmipcException): + """Wait for registration to the HomematicIP cloud server.""" + + +class HmipcRegistrationFailed(HmipcException): + """Registration on HomematicIP cloud failed.""" + + +class HmipcPressButton(HmipcException): + """User needs to press the blue button.""" diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py new file mode 100644 index 00000000000..a4e3e78e860 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -0,0 +1,256 @@ +"""Accesspoint for the HomematicIP Cloud component.""" +import asyncio +import logging + +from homeassistant import config_entries +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import callback + +from .const import ( + HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME, + COMPONENTS) +from .errors import HmipcConnectionError + +_LOGGER = logging.getLogger(__name__) + + +class HomematicipAuth(object): + """Manages HomematicIP client registration.""" + + def __init__(self, hass, config): + """Initialize HomematicIP Cloud client registration.""" + self.hass = hass + self.config = config + self.auth = None + + async def async_setup(self): + """Connect to HomematicIP for registration.""" + try: + self.auth = await self.get_auth( + self.hass, + self.config.get(HMIPC_HAPID), + self.config.get(HMIPC_PIN) + ) + return True + except HmipcConnectionError: + return False + + async def async_checkbutton(self): + """Check blue butten has been pressed.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + await self.auth.isRequestAcknowledged() + return True + except HmipConnectionError: + return False + + async def async_register(self): + """Register client at HomematicIP.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + authtoken = await self.auth.requestAuthToken() + await self.auth.confirmAuthToken(authtoken) + return authtoken + except HmipConnectionError: + return False + + async def get_auth(self, hass, hapid, pin): + """Create a HomematicIP access point object.""" + from homematicip.aio.auth import AsyncAuth + from homematicip.base.base_connection import HmipConnectionError + + auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) + print(auth) + try: + await auth.init(hapid) + if pin: + auth.pin = pin + await auth.connectionRequest('HomeAssistant') + except HmipConnectionError: + return False + return auth + + +class HomematicipHAP(object): + """Manages HomematicIP http and websocket connection.""" + + def __init__(self, hass, config_entry): + """Initialize HomematicIP cloud connection.""" + self.hass = hass + self.config_entry = config_entry + self.home = None + + self._ws_close_requested = False + self._retry_task = None + self._tries = 0 + self._accesspoint_connected = True + self._retry_setup = None + + async def async_setup(self, tries=0): + """Initialize connection.""" + try: + self.home = await self.get_hap( + self.hass, + self.config_entry.data.get(HMIPC_HAPID), + self.config_entry.data.get(HMIPC_AUTHTOKEN), + self.config_entry.data.get(HMIPC_NAME) + ) + except HmipcConnectionError: + retry_delay = 2 ** min(tries + 1, 6) + _LOGGER.error("Error connecting to HomematicIP with HAP %s. " + "Retrying in %d seconds.", + self.config_entry.data.get(HMIPC_HAPID), retry_delay) + + async def retry_setup(_now): + """Retry setup.""" + if await self.async_setup(tries + 1): + self.config_entry.state = config_entries.ENTRY_STATE_LOADED + + self._retry_setup = self.hass.helpers.event.async_call_later( + retry_delay, retry_setup) + + return False + + _LOGGER.info('Connected to HomematicIP with HAP %s.', + self.config_entry.data.get(HMIPC_HAPID)) + + for component in COMPONENTS: + self.hass.async_add_job( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, component) + ) + return True + + @callback + def async_update(self, *args, **kwargs): + """Async update the home device. + + Triggered when the hmip HOME_CHANGED event has fired. + There are several occasions for this event to happen. + We are only interested to check whether the access point + is still connected. If not, device state changes cannot + be forwarded to hass. So if access point is disconnected all devices + are set to unavailable. + """ + if not self.home.connected: + _LOGGER.error( + "HMIP access point has lost connection with the cloud") + self._accesspoint_connected = False + self.set_all_to_unavailable() + elif not self._accesspoint_connected: + # Explicitly getting an update as device states might have + # changed during access point disconnect.""" + + job = self.hass.async_add_job(self.get_state()) + job.add_done_callback(self.get_state_finished) + + async def get_state(self): + """Update hmip state and tell hass.""" + await self.home.get_current_state() + self.update_all() + + def get_state_finished(self, future): + """Execute when get_state coroutine has finished.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + future.result() + except HmipConnectionError: + # Somehow connection could not recover. Will disconnect and + # so reconnect loop is taking over. + _LOGGER.error( + "updating state after himp access point reconnect failed.") + self.hass.async_add_job(self.home.disable_events()) + + def set_all_to_unavailable(self): + """Set all devices to unavailable and tell Hass.""" + for device in self.home.devices: + device.unreach = True + self.update_all() + + def update_all(self): + """Signal all devices to update their state.""" + for device in self.home.devices: + device.fire_update_event() + + async def _handle_connection(self): + """Handle websocket connection.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + await self.home.get_current_state() + except HmipConnectionError: + return + hmip_events = await self.home.enable_events() + try: + await hmip_events + except HmipConnectionError: + return + + async def async_connect(self): + """Start websocket connection.""" + from homematicip.base.base_connection import HmipConnectionError + + tries = 0 + while True: + try: + await self.home.get_current_state() + hmip_events = await self.home.enable_events() + tries = 0 + await hmip_events + except HmipConnectionError: + pass + + if self._ws_close_requested: + break + self._ws_close_requested = False + + tries += 1 + retry_delay = 2 ** min(tries + 1, 6) + _LOGGER.error("Error connecting to HomematicIP with HAP %s. " + "Retrying in %d seconds.", + self.config_entry.data.get(HMIPC_HAPID), retry_delay) + try: + self._retry_task = self.hass.async_add_job(asyncio.sleep( + retry_delay, loop=self.hass.loop)) + await self._retry_task + except asyncio.CancelledError: + break + + async def async_reset(self): + """Close the websocket connection.""" + self._ws_close_requested = True + if self._retry_setup is not None: + self._retry_setup.cancel() + if self._retry_task is not None: + self._retry_task.cancel() + self.home.disable_events() + _LOGGER.info("Closed connection to HomematicIP cloud server.") + for component in COMPONENTS: + await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, component) + return True + + async def get_hap(self, hass, hapid, authtoken, name): + """Create a HomematicIP access point object.""" + from homematicip.aio.home import AsyncHome + from homematicip.base.base_connection import HmipConnectionError + + home = AsyncHome(hass.loop, async_get_clientsession(hass)) + + home.name = name + home.label = 'Access Point' + home.modelType = 'HmIP-HAP' + + home.set_auth_token(authtoken) + try: + await home.init(hapid) + await home.get_current_state() + except HmipConnectionError: + raise HmipcConnectionError + home.on_update(self.async_update) + hass.loop.create_task(self.async_connect()) + + return home diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json new file mode 100644 index 00000000000..887a3a5780b --- /dev/null +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "HomematicIP Cloud", + "step": { + "init": { + "title": "Pick HomematicIP Accesspoint", + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "pin": "Pin Code (optional)", + "name": "Name (optional, used as name prefix for all devices)" + } + }, + "link": { + "title": "Link Accesspoint", + "description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + } + }, + "error": { + "register_failed": "Failed to register, please try again.", + "invalid_pin": "Invalid PIN, please try again.", + "press_the_button": "Please press the blue button.", + "timeout_button": "Blue button press timeout, please try again." + }, + "abort": { + "unknown": "Unknown error occurred.", + "conection_aborted": "Could not connect to HMIP server", + "already_configured": "Accesspoint is already configured" + } + } +} diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index c8eba41e66b..0cbee628a8a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -187,8 +187,7 @@ class HomeAssistantHTTP(object): support_legacy=hass.auth.support_legacy, api_password=api_password) - if cors_origins: - setup_cors(app, cors_origins) + setup_cors(app, cors_origins) app['hass'] = hass @@ -226,7 +225,7 @@ class HomeAssistantHTTP(object): '{0} missing required attribute "name"'.format(class_name) ) - view.register(self.app.router) + view.register(self.app, self.app.router) def register_redirect(self, url, redirect_to): """Register a redirect with the server. diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index a232d9295a4..2cc62dce38e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -27,7 +27,8 @@ def setup_auth(app, trusted_networks, use_auth, if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or DATA_API_PASSWORD in request.query): - _LOGGER.warning('Please use access_token instead api_password.') + _LOGGER.warning('Please change to use bearer token access %s', + request.path) legacy_auth = (not use_auth or support_legacy) and api_password if (hdrs.AUTHORIZATION in request.headers and diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index fe8b7db84d1..e05f951322e 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -72,7 +72,11 @@ async def ban_middleware(request, handler): async def process_wrong_login(request): - """Process a wrong login attempt.""" + """Process a wrong login attempt. + + Increase failed login attempts counter for remote IP address. + Add ip ban entry if failed login attempts exceeds threshold. + """ remote_addr = request[KEY_REAL_IP] msg = ('Login attempt or request with invalid authentication ' @@ -107,7 +111,28 @@ async def process_wrong_login(request): 'Banning IP address', NOTIFICATION_ID_BAN) -class IpBan(object): +async def process_success_login(request): + """Process a success login attempt. + + Reset failed login attempts counter for remote IP address. + No release IP address from banned list function, it can only be done by + manual modify ip bans config file. + """ + remote_addr = request[KEY_REAL_IP] + + # Check if ban middleware is loaded + if (KEY_BANNED_IPS not in request.app or + request.app[KEY_LOGIN_THRESHOLD] < 1): + return + + if remote_addr in request.app[KEY_FAILED_LOGIN_ATTEMPTS] and \ + request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > 0: + _LOGGER.debug('Login success, reset failed login attempts counter' + ' from %s', remote_addr) + request.app[KEY_FAILED_LOGIN_ATTEMPTS].pop(remote_addr) + + +class IpBan: """Represents banned IP address.""" def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 0a37f22867e..b01e68f701d 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -27,6 +27,20 @@ def setup_cors(app, origins): ) for host in origins }) + def allow_cors(route, methods): + """Allow cors on a route.""" + cors.add(route, { + '*': aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, + allow_methods=methods, + ) + }) + + app['allow_cors'] = allow_cors + + if not origins: + return + async def cors_startup(app): """Initialize cors when app starts up.""" cors_added = set() diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 3de276564eb..7823d674ab3 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -12,6 +12,7 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError import homeassistant.remote as rem +from homeassistant.components.http.ban import process_success_login from homeassistant.core import is_callback from homeassistant.const import CONTENT_TYPE_JSON @@ -26,7 +27,9 @@ class HomeAssistantView(object): url = None extra_urls = [] - requires_auth = True # Views inheriting from this class can override this + # Views inheriting from this class can override this + requires_auth = True + cors_allowed = False # pylint: disable=no-self-use def json(self, result, status_code=200, headers=None): @@ -51,10 +54,11 @@ class HomeAssistantView(object): data['code'] = message_code return self.json(data, status_code, headers=headers) - def register(self, router): + def register(self, app, router): """Register the view with a router.""" assert self.url is not None, 'No url set for view' urls = [self.url] + self.extra_urls + routes = [] for method in ('get', 'post', 'delete', 'put'): handler = getattr(self, method, None) @@ -65,13 +69,15 @@ class HomeAssistantView(object): handler = request_handler_factory(self, handler) for url in urls: - router.add_route(method, url, handler) + routes.append( + (method, router.add_route(method, url, handler)) + ) - # aiohttp_cors does not work with class based views - # self.app.router.add_route('*', self.url, self, name=self.name) + if not self.cors_allowed: + return - # for url in self.extra_urls: - # self.app.router.add_route('*', url, self) + for method, route in routes: + app['allow_cors'](route, [method.upper()]) def request_handler_factory(view, handler): @@ -86,8 +92,11 @@ def request_handler_factory(view, handler): authenticated = request.get(KEY_AUTHENTICATED, False) - if view.requires_auth and not authenticated: - raise HTTPUnauthorized() + if view.requires_auth: + if authenticated: + await process_success_login(request) + else: + raise HTTPUnauthorized() _LOGGER.info('Serving %s to %s (auth: %s)', request.path, request.get(KEY_REAL_IP), authenticated) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 29f26cc84e6..480ec31da7d 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -69,27 +69,32 @@ SERVICE_SCAN_SCHEMA = vol.Schema({ @bind_hass def scan(hass, entity_id=None): - """Force process an image.""" + """Force process of all cameras or given entity.""" + hass.add_job(async_scan, hass, entity_id) + + +@callback +@bind_hass +def async_scan(hass, entity_id=None): + """Force process of all cameras or given entity.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_SCAN, data) + hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SCAN, data)) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the image processing.""" component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_scan_service(service): + async def async_scan_service(service): """Service handler for scan.""" image_entities = component.async_extract_from_service(service) update_task = [entity.async_update_ha_state(True) for entity in image_entities] if update_task: - yield from asyncio.wait(update_task, loop=hass.loop) + await asyncio.wait(update_task, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SCAN, async_scan_service, @@ -124,8 +129,7 @@ class ImageProcessingEntity(Entity): """ return self.hass.async_add_job(self.process_image, image) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update image and process it. This method is a coroutine. @@ -134,7 +138,7 @@ class ImageProcessingEntity(Entity): image = None try: - image = yield from camera.async_get_image( + image = await camera.async_get_image( self.camera_entity, timeout=self.timeout) except HomeAssistantError as err: @@ -142,7 +146,7 @@ class ImageProcessingEntity(Entity): return # process image data - yield from self.async_process_image(image.content) + await self.async_process_image(image.content) class ImageProcessingFaceEntity(ImageProcessingEntity): diff --git a/homeassistant/components/image_processing/facebox.py b/homeassistant/components/image_processing/facebox.py index f556b62e935..c863f804513 100644 --- a/homeassistant/components/image_processing/facebox.py +++ b/homeassistant/components/image_processing/facebox.py @@ -10,20 +10,26 @@ import logging import requests import voluptuous as vol -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_NAME) from homeassistant.core import split_entity_id 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) + CONF_ENTITY_ID, CONF_NAME, DOMAIN) from homeassistant.const import (CONF_IP_ADDRESS, CONF_PORT) _LOGGER = logging.getLogger(__name__) ATTR_BOUNDING_BOX = 'bounding_box' +ATTR_CLASSIFIER = 'classifier' ATTR_IMAGE_ID = 'image_id' ATTR_MATCHED = 'matched' 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 @@ -32,6 +38,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PORT): cv.port, }) +SERVICE_TEACH_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string, + vol.Required(FILE_PATH): cv.string, +}) + def encode_image(image): """base64 encode an image stream.""" @@ -63,18 +75,65 @@ def parse_faces(api_faces): return known_faces +def post_image(url, image): + """Post an image to the classifier.""" + try: + response = requests.post( + url, + json={"base64": encode_image(image)}, + timeout=TIMEOUT + ) + return response + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + + +def valid_file_path(file_path): + """Check that a file_path points to a valid file.""" + try: + cv.isfile(file_path) + return True + except vol.Invalid: + _LOGGER.error( + "%s error: Invalid file path: %s", CLASSIFIER, file_path) + return False + + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the classifier.""" + if DATA_FACEBOX not in hass.data: + hass.data[DATA_FACEBOX] = [] + entities = [] for camera in config[CONF_SOURCE]: - entities.append(FaceClassifyEntity( + facebox = FaceClassifyEntity( config[CONF_IP_ADDRESS], config[CONF_PORT], camera[CONF_ENTITY_ID], - camera.get(CONF_NAME) - )) + camera.get(CONF_NAME)) + entities.append(facebox) + hass.data[DATA_FACEBOX].append(facebox) add_devices(entities) + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get('entity_id') + + classifiers = hass.data[DATA_FACEBOX] + if entity_ids: + classifiers = [c for c in classifiers if c.entity_id in entity_ids] + + for classifier in classifiers: + name = service.data.get(ATTR_NAME) + file_path = service.data.get(FILE_PATH) + classifier.teach(name, file_path) + + hass.services.register( + DOMAIN, + SERVICE_TEACH_FACE, + service_handle, + schema=SERVICE_TEACH_SCHEMA) + class FaceClassifyEntity(ImageProcessingFaceEntity): """Perform a face classification.""" @@ -82,7 +141,8 @@ class FaceClassifyEntity(ImageProcessingFaceEntity): def __init__(self, ip, port, camera_entity, name=None): """Init with the API key and model id.""" super().__init__() - self._url = "http://{}:{}/{}/check".format(ip, port, CLASSIFIER) + self._url_check = "http://{}:{}/{}/check".format(ip, port, CLASSIFIER) + self._url_teach = "http://{}:{}/{}/teach".format(ip, port, CLASSIFIER) self._camera = camera_entity if name: self._name = name @@ -94,28 +154,54 @@ class FaceClassifyEntity(ImageProcessingFaceEntity): def process_image(self, image): """Process an image.""" - response = {} - try: - response = requests.post( - self._url, - json={"base64": encode_image(image)}, - timeout=TIMEOUT - ).json() - except requests.exceptions.ConnectionError: - _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) - response['success'] = False - - if response['success']: - total_faces = response['facesCount'] - faces = parse_faces(response['faces']) - self._matched = get_matched_faces(faces) - self.process_faces(faces, total_faces) + response = post_image(self._url_check, image) + if response is not None: + response_json = response.json() + if response_json['success']: + total_faces = response_json['facesCount'] + faces = parse_faces(response_json['faces']) + self._matched = get_matched_faces(faces) + self.process_faces(faces, total_faces) else: self.total_faces = None self.faces = [] self._matched = {} + def teach(self, name, file_path): + """Teach classifier a face name.""" + 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 + }) + @property def camera_entity(self): """Return camera entity id from process pictures.""" @@ -131,4 +217,5 @@ class FaceClassifyEntity(ImageProcessingFaceEntity): """Return the classifier attributes.""" return { 'matched_faces': self._matched, + 'total_matched_faces': len(self._matched), } diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index 1f1fa347dc9..0689c34c1a3 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -6,3 +6,16 @@ scan: entity_id: description: Name(s) of entities to scan immediately. example: 'image_processing.alpr_garage' + +facebox_teach_face: + description: Teach facebox a face using a file. + fields: + entity_id: + description: The facebox entity to teach. + example: 'image_processing.facebox' + name: + description: The name of the face to teach. + example: 'my_name' + file_path: + description: The path to the image file. + example: '/images/my_image.jpg' diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 05907ea86ee..08d7f5773f7 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -174,7 +174,7 @@ class DeconzLight(Light): data = {'on': False} if ATTR_TRANSITION in kwargs: - data = {'bri': 0} + data['bri'] = 0 data['transitiontime'] = int(kwargs[ATTR_TRANSITION]) * 10 if ATTR_FLASH in kwargs: diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py index 6f0a8816eea..2e7370cb336 100644 --- a/homeassistant/components/light/eufy.py +++ b/homeassistant/components/light/eufy.py @@ -36,7 +36,6 @@ class EufyLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error import lakeside self._temp = None diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index b9db9d4f99b..c5cd9a8c4fd 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -218,6 +218,9 @@ class FluxLight(Light): def turn_on(self, **kwargs): """Turn the specified or all lights on.""" + if not self.is_on: + self._bulb.turnOn() + hs_color = kwargs.get(ATTR_HS_COLOR) if hs_color: @@ -269,9 +272,6 @@ class FluxLight(Light): else: self._bulb.setRgb(*tuple(rgb), brightness=brightness) - if not self.is_on: - self._bulb.turnOn() - def turn_off(self, **kwargs): """Turn the specified or all lights off.""" self._bulb.turnOff() diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py index e433da44ae7..617a7209a86 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -7,33 +7,39 @@ https://home-assistant.io/components/light.homematicip_cloud/ import logging -from homeassistant.components.light import Light +from homeassistant.components.light import ( + Light, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS) from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) DEPENDENCIES = ['homematicip_cloud'] _LOGGER = logging.getLogger(__name__) ATTR_POWER_CONSUMPTION = 'power_consumption' -ATTR_ENERGIE_COUNTER = 'energie_counter' +ATTR_ENERGIE_COUNTER = 'energie_counter_kwh' ATTR_PROFILE_MODE = 'profile_mode' async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the HomematicIP light devices.""" - from homematicip.device import ( - BrandSwitchMeasuring) + """Old way of setting up HomematicIP lights.""" + pass - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP lights from a config entry.""" + from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring, AsyncDimmer) + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: - if isinstance(device, BrandSwitchMeasuring): + if isinstance(device, AsyncBrandSwitchMeasuring): devices.append(HomematicipLightMeasuring(home, device)) + elif isinstance(device, AsyncDimmer): + devices.append(HomematicipDimmer(home, device)) if devices: async_add_devices(devices) @@ -64,13 +70,50 @@ class HomematicipLightMeasuring(HomematicipLight): """MomematicIP measuring light device.""" @property - def current_power_w(self): - """Return the current power usage in W.""" - return self._device.currentPowerConsumption + def device_state_attributes(self): + """Return the state attributes of the generic device.""" + attr = super().device_state_attributes + if self._device.currentPowerConsumption > 0.05: + attr.update({ + ATTR_POWER_CONSUMPTION: + round(self._device.currentPowerConsumption, 2) + }) + attr.update({ + ATTR_ENERGIE_COUNTER: round(self._device.energyCounter, 2) + }) + return attr + + +class HomematicipDimmer(HomematicipGenericDevice, Light): + """MomematicIP dimmer light device.""" + + def __init__(self, home, device): + """Initialize the dimmer light device.""" + super().__init__(home, device) @property - def today_energy_kwh(self): - """Return the today total energy usage in kWh.""" - if self._device.energyCounter is None: - return 0 - return round(self._device.energyCounter) + def is_on(self): + """Return true if device is on.""" + return self._device.dimLevel != 0 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._device.dimLevel*255) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + await self._device.set_dim_level( + kwargs[ATTR_BRIGHTNESS]/255.0) + else: + await self._device.set_dim_level(1) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._device.set_dim_level(0) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 71d3f9d95d7..2263a865758 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -21,7 +21,7 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_hs_to_RGB) from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.1.0'] +REQUIREMENTS = ['limitlessled==1.1.2'] _LOGGER = logging.getLogger(__name__) @@ -46,7 +46,7 @@ MIN_SATURATION = 10 WHITE = [0, 0] SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | - SUPPORT_TRANSITION) + SUPPORT_EFFECT | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_DIMMER = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_FLASH | SUPPORT_COLOR | @@ -239,6 +239,8 @@ class LimitlessLEDGroup(Light): @property def color_temp(self): """Return the temperature property.""" + if self.hs_color is not None: + return None return self._temperature @property @@ -247,6 +249,9 @@ class LimitlessLEDGroup(Light): if self._effect == EFFECT_NIGHT: return None + if self._color is None or self._color[1] == 0: + return None + return self._color @property diff --git a/homeassistant/components/light/mystrom.py b/homeassistant/components/light/mystrom.py index 9abd96664f2..5d4cdcc17d4 100644 --- a/homeassistant/components/light/mystrom.py +++ b/homeassistant/components/light/mystrom.py @@ -155,7 +155,11 @@ class MyStromLight(Light): self._state = self._bulb.get_status() colors = self._bulb.get_color()['color'] - color_h, color_s, color_v = colors.split(';') + try: + color_h, color_s, color_v = colors.split(';') + except ValueError: + color_s, color_v = colors.split(';') + color_h = 0 self._color_h = int(color_h) self._color_s = int(color_s) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 09a4fa3610d..669901f5b57 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -66,6 +66,8 @@ class TPLinkSmartBulb(Light): self._brightness = None self._hs = None self._supported_features = 0 + self._min_mireds = None + self._max_mireds = None self._emeter_params = {} @property @@ -107,12 +109,12 @@ class TPLinkSmartBulb(Light): @property def min_mireds(self): """Return minimum supported color temperature.""" - return kelvin_to_mired(self.smartbulb.valid_temperature_range[1]) + return self._min_mireds @property def max_mireds(self): """Return maximum supported color temperature.""" - return kelvin_to_mired(self.smartbulb.valid_temperature_range[0]) + return self._max_mireds @property def color_temp(self): @@ -195,5 +197,9 @@ class TPLinkSmartBulb(Light): self._supported_features += SUPPORT_BRIGHTNESS if self.smartbulb.is_variable_color_temp: self._supported_features += SUPPORT_COLOR_TEMP + self._min_mireds = kelvin_to_mired( + self.smartbulb.valid_temperature_range[1]) + self._max_mireds = kelvin_to_mired( + self.smartbulb.valid_temperature_range[0]) if self.smartbulb.is_color: self._supported_features += SUPPORT_COLOR diff --git a/homeassistant/components/light/tuya.py b/homeassistant/components/light/tuya.py new file mode 100644 index 00000000000..d7691cea011 --- /dev/null +++ b/homeassistant/components/light/tuya.py @@ -0,0 +1,102 @@ +""" +Support for the Tuya light. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.tuya/ +""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ENTITY_ID_FORMAT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, Light) + +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice +from homeassistant.util import color as colorutil + +DEPENDENCIES = ['tuya'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya light platform.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaLight(device)) + add_devices(devices) + + +class TuyaLight(TuyaDevice, Light): + """Tuya light device.""" + + def __init__(self, tuya): + """Init Tuya light device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + + @property + def brightness(self): + """Return the brightness of the light.""" + return self.tuya.brightness() + + @property + def hs_color(self): + """Return the hs_color of the light.""" + return self.tuya.hs_color() + + @property + def color_temp(self): + """Return the color_temp of the light.""" + color_temp = self.tuya.color_temp() + if color_temp is None: + return None + return colorutil.color_temperature_kelvin_to_mired(color_temp) + + @property + def is_on(self): + """Return true if light is on.""" + return self.tuya.state() + + @property + def min_mireds(self): + """Return color temperature min mireds.""" + return colorutil.color_temperature_kelvin_to_mired( + self.tuya.min_color_temp()) + + @property + def max_mireds(self): + """Return color temperature max mireds.""" + return colorutil.color_temperature_kelvin_to_mired( + self.tuya.max_color_temp()) + + def turn_on(self, **kwargs): + """Turn on or control the light.""" + if (ATTR_BRIGHTNESS not in kwargs + and ATTR_HS_COLOR not in kwargs + and ATTR_COLOR_TEMP not in kwargs): + self.tuya.turn_on() + if ATTR_BRIGHTNESS in kwargs: + self.tuya.set_brightness(kwargs[ATTR_BRIGHTNESS]) + if ATTR_HS_COLOR in kwargs: + self.tuya.set_color(kwargs[ATTR_HS_COLOR]) + if ATTR_COLOR_TEMP in kwargs: + color_temp = colorutil.color_temperature_mired_to_kelvin( + kwargs[ATTR_COLOR_TEMP]) + self.tuya.set_color_temp(color_temp) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self.tuya.turn_off() + + @property + def supported_features(self): + """Flag supported features.""" + supports = SUPPORT_BRIGHTNESS + if self.tuya.support_color(): + supports = supports | SUPPORT_COLOR + if self.tuya.support_color_temp(): + supports = supports | SUPPORT_COLOR_TEMP + return supports diff --git a/homeassistant/components/light/xiaomi_aqara.py b/homeassistant/components/light/xiaomi_aqara.py index 37ae60e3494..75c85a4bfcf 100644 --- a/homeassistant/components/light/xiaomi_aqara.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -31,7 +31,7 @@ class XiaomiGatewayLight(XiaomiDevice, Light): """Initialize the XiaomiGatewayLight.""" self._data_key = 'rgb' self._hs = (0, 0) - self._brightness = 180 + self._brightness = 100 XiaomiDevice.__init__(self, device, name, xiaomi_hub) @@ -64,7 +64,7 @@ class XiaomiGatewayLight(XiaomiDevice, Light): brightness = rgba[0] rgb = rgba[1:] - self._brightness = int(255 * brightness / 100) + self._brightness = brightness self._hs = color_util.color_RGB_to_hs(*rgb) self._state = True return True @@ -72,7 +72,7 @@ class XiaomiGatewayLight(XiaomiDevice, Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness + return int(255 * self._brightness / 100) @property def hs_color(self): diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 202c6ac594d..791de291b48 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -310,7 +310,7 @@ class YeelightLight(Light): bright = self._properties.get('bright', None) if bright: - self._brightness = 255 * (int(bright) / 100) + self._brightness = round(255 * (int(bright) / 100)) temp_in_k = self._properties.get('ct', None) if temp_in_k: diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 3bfa167f8ec..f468e8c25ef 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -324,9 +324,11 @@ class ZwaveColorLight(ZwaveDimmer): else: self._ct = TEMP_COLD_HASS rgbw = '#00000000ff' - elif ATTR_HS_COLOR in kwargs: self._hs = kwargs[ATTR_HS_COLOR] + if ATTR_WHITE_VALUE not in kwargs: + # white LED must be off in order for color to work + self._white = 0 if ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs: rgbw = '#' diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index e2d02acc61c..eb2e8391221 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -83,7 +83,7 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None): hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data) -async def setup(hass, config): +async def async_setup(hass, config): """Listen for download events to download files.""" @callback def log_message(service): diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 85895fdd751..21accdf84b3 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.06.25'] +REQUIREMENTS = ['youtube_dl==2018.07.04'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index ff0e4d907b1..2b2b9eb5c28 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -12,19 +12,19 @@ import voluptuous as vol from homeassistant.components.media_player import ( SUPPORT_PAUSE, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, - MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_TURN_ON, - MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) + SUPPORT_SELECT_SOURCE, SUPPORT_SELECT_SOUND_MODE, + SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, MediaPlayerDevice, + PLATFORM_SCHEMA, SUPPORT_TURN_ON, MEDIA_TYPE_MUSIC, + SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.7.3'] +REQUIREMENTS = ['denonavr==0.7.4'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False DEFAULT_TIMEOUT = 2 CONF_SHOW_ALL_SOURCES = 'show_all_sources' @@ -33,6 +33,8 @@ CONF_VALID_ZONES = ['Zone2', 'Zone3'] CONF_INVALID_ZONES_ERR = 'Invalid Zone (expected Zone2 or Zone3)' KEY_DENON_CACHE = 'denonavr_hosts' +ATTR_SOUND_MODE_RAW = 'sound_mode_raw' + SUPPORT_DENON = SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET @@ -146,6 +148,20 @@ class DenonDevice(MediaPlayerDevice): self._frequency = self._receiver.frequency self._station = self._receiver.station + self._sound_mode_support = self._receiver.support_sound_mode + if self._sound_mode_support: + self._sound_mode = self._receiver.sound_mode + self._sound_mode_raw = self._receiver.sound_mode_raw + self._sound_mode_list = self._receiver.sound_mode_list + else: + self._sound_mode = None + self._sound_mode_raw = None + self._sound_mode_list = None + + self._supported_features_base = SUPPORT_DENON + self._supported_features_base |= (self._sound_mode_support and + SUPPORT_SELECT_SOUND_MODE) + def update(self): """Get the latest status information from device.""" self._receiver.update() @@ -163,6 +179,9 @@ class DenonDevice(MediaPlayerDevice): self._band = self._receiver.band self._frequency = self._receiver.frequency self._station = self._receiver.station + if self._sound_mode_support: + self._sound_mode = self._receiver.sound_mode + self._sound_mode_raw = self._receiver.sound_mode_raw @property def name(self): @@ -196,12 +215,22 @@ class DenonDevice(MediaPlayerDevice): """Return a list of available input sources.""" return self._source_list + @property + def sound_mode(self): + """Return the current matched sound mode.""" + return self._sound_mode + + @property + def sound_mode_list(self): + """Return a list of available sound modes.""" + return self._sound_mode_list + @property def supported_features(self): """Flag media player features that are supported.""" if self._current_source in self._receiver.netaudio_func_list: - return SUPPORT_DENON | SUPPORT_MEDIA_MODES - return SUPPORT_DENON + return self._supported_features_base | SUPPORT_MEDIA_MODES + return self._supported_features_base @property def media_content_id(self): @@ -275,6 +304,15 @@ class DenonDevice(MediaPlayerDevice): """Episode of current playing media, TV show only.""" return None + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if (self._sound_mode_raw is not None and self._sound_mode_support and + self._power == 'ON'): + attributes[ATTR_SOUND_MODE_RAW] = self._sound_mode_raw + return attributes + def media_play_pause(self): """Simulate play pause media player.""" return self._receiver.toggle_play_pause() @@ -291,6 +329,10 @@ class DenonDevice(MediaPlayerDevice): """Select input source.""" return self._receiver.set_input_func(source) + def select_sound_mode(self, sound_mode): + """Select sound mode.""" + return self._receiver.set_sound_mode(sound_mode) + def turn_on(self): """Turn on media player.""" if self._receiver.power_on(): diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index 8c98844cf93..df1ee662124 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -20,8 +20,7 @@ from homeassistant.const import ( STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) import homeassistant.util as util -REQUIREMENTS = ['https://github.com/wokar/pylgnetcast/archive/' - 'v0.2.0.zip#pylgnetcast==0.2.0'] +REQUIREMENTS = ['pylgnetcast-homeassistant==0.2.0.dev0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 4fe4da5a942..6b161f86ab0 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -88,6 +88,8 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): import pyteleloisirs try: self._state = self.refresh_state() + # Update channel list + self.refresh_channel_list() # Update current channel channel = self._client.channel if channel is not None: diff --git a/homeassistant/components/media_player/pandora.py b/homeassistant/components/media_player/pandora.py index a47db7f633c..90638cd9dfc 100644 --- a/homeassistant/components/media_player/pandora.py +++ b/homeassistant/components/media_player/pandora.py @@ -22,7 +22,7 @@ from homeassistant.const import (STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_IDLE) from homeassistant import util -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) # SUPPORT_VOLUME_SET is close to available but we need volume up/down diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py index be0c0527f1b..06f054a03f7 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.helpers.script import Script from homeassistant.util import Throttle -REQUIREMENTS = ['ha-philipsjs==0.0.4'] +REQUIREMENTS = ['ha-philipsjs==0.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 3aa8e82911e..3066819638f 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.14.0'] +REQUIREMENTS = ['pymysensors==0.16.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py new file mode 100644 index 00000000000..6dea5919f09 --- /dev/null +++ b/homeassistant/components/onboarding/__init__.py @@ -0,0 +1,56 @@ +"""Component to help onboard new users.""" +from homeassistant.core import callback +from homeassistant.loader import bind_hass + +from .const import STEPS, STEP_USER, DOMAIN + +DEPENDENCIES = ['http'] +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + + +@bind_hass +@callback +def async_is_onboarded(hass): + """Return if Home Assistant has been onboarded.""" + # Temporarily: if auth not active, always set onboarded=True + if not hass.auth.active: + return True + + return hass.data.get(DOMAIN, True) + + +async def async_setup(hass, config): + """Set up the onboard component.""" + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + data = await store.async_load() + + if data is None: + data = { + 'done': [] + } + + if STEP_USER not in data['done']: + # Users can already have created an owner account via the command line + # If so, mark the user step as done. + has_owner = False + + for user in await hass.auth.async_get_users(): + if user.is_owner: + has_owner = True + break + + if has_owner: + data['done'].append(STEP_USER) + await store.async_save(data) + + if set(data['done']) == set(STEPS): + return True + + hass.data[DOMAIN] = False + + from . import views + + await views.async_setup(hass, data, store) + + return True diff --git a/homeassistant/components/onboarding/const.py b/homeassistant/components/onboarding/const.py new file mode 100644 index 00000000000..3aa106ac18c --- /dev/null +++ b/homeassistant/components/onboarding/const.py @@ -0,0 +1,7 @@ +"""Constants for the onboarding component.""" +DOMAIN = 'onboarding' +STEP_USER = 'user' + +STEPS = [ + STEP_USER +] diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py new file mode 100644 index 00000000000..17d83003c48 --- /dev/null +++ b/homeassistant/components/onboarding/views.py @@ -0,0 +1,107 @@ +"""Onboarding views.""" +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 .const import DOMAIN, STEPS, STEP_USER + + +async def async_setup(hass, data, store): + """Setup onboarding.""" + hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(UserOnboardingView(data, store)) + + +class OnboardingView(HomeAssistantView): + """Returns the onboarding status.""" + + requires_auth = False + url = '/api/onboarding' + name = 'api:onboarding' + + def __init__(self, data, store): + """Initialize the onboarding view.""" + self._store = store + self._data = data + + async def get(self, request): + """Return the onboarding status.""" + return self.json([ + { + 'step': key, + 'done': key in self._data['done'], + } for key in STEPS + ]) + + +class _BaseOnboardingView(HomeAssistantView): + """Base class for onboarding.""" + + requires_auth = False + step = None + + def __init__(self, data, store): + """Initialize the onboarding view.""" + self._store = store + self._data = data + self._lock = asyncio.Lock() + + @callback + def _async_is_done(self): + """Return if this step is done.""" + return self.step in self._data['done'] + + async def _async_mark_done(self, hass): + """Mark step as done.""" + self._data['done'].append(self.step) + await self._store.async_save(self._data) + + hass.data[DOMAIN] = len(self._data) == len(STEPS) + + +class UserOnboardingView(_BaseOnboardingView): + """View to handle onboarding.""" + + url = '/api/onboarding/users' + name = 'api:onboarding:users' + step = STEP_USER + + @RequestDataValidator(vol.Schema({ + vol.Required('name'): str, + vol.Required('username'): str, + vol.Required('password'): str, + })) + async def post(self, request, data): + """Return the manifest.json.""" + hass = request.app['hass'] + + async with self._lock: + if self._async_is_done(): + return self.json_message('User step already done', 403) + + provider = _async_get_hass_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_create_user(data['name']) + await hass.async_add_executor_job( + provider.data.add_auth, data['username'], data['password']) + credentials = await provider.async_get_or_create_credentials({ + 'username': data['username'] + }) + await provider.data.async_save() + await hass.auth.async_link_user(user, credentials) + await self._async_mark_done(hass) + + +@callback +def _async_get_hass_provider(hass): + """Get the Home Assistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == 'homeassistant': + return prv + + raise RuntimeError('No Home Assistant provider found') diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 4574437bac9..86594b74995 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -4,8 +4,6 @@ Register an iFrame front end panel. For more details about this component, please refer to the documentation at https://home-assistant.io/components/panel_iframe/ """ -import asyncio - import voluptuous as vol from homeassistant.const import (CONF_ICON, CONF_URL) @@ -34,11 +32,10 @@ CONFIG_SCHEMA = vol.Schema({ }})}, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def setup(hass, config): +async def async_setup(hass, config): """Set up the iFrame frontend panels.""" for url_path, info in config[DOMAIN].items(): - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), url_path, {'url': info[CONF_URL]}) diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 6f233dafe08..0a6c959f243 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -10,6 +10,7 @@ import logging import voluptuous as vol from aiohttp import web +from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, @@ -180,6 +181,15 @@ class PrometheusMetrics(object): 'Temperature in degrees Celsius') metric.labels(**self._labels(state)).set(temp) + current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if current_temp: + if unit == TEMP_FAHRENHEIT: + current_temp = fahrenheit_to_celsius(current_temp) + metric = self._metric( + 'current_temperature_c', self.prometheus_client.Gauge, + 'Current Temperature in degrees Celsius') + metric.labels(**self._labels(state)).set(current_temp) + metric = self._metric( 'climate_state', self.prometheus_client.Gauge, 'State of the thermostat (0/1)') diff --git a/homeassistant/components/rachio.py b/homeassistant/components/rachio.py index b3b2d05e933..3a804c50c74 100644 --- a/homeassistant/components/rachio.py +++ b/homeassistant/components/rachio.py @@ -10,7 +10,7 @@ import logging from aiohttp import web import voluptuous as vol -from homeassistant.auth import generate_secret +from homeassistant.auth.util import generate_secret from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 38ba593261f..43c2aa5c7b1 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,7 +35,7 @@ from . import migration, purge from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.8'] +REQUIREMENTS = ['sqlalchemy==1.2.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py index 18029691dc7..609887e9690 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -13,7 +13,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.arlo import ( CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS) +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) + from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -28,7 +31,10 @@ SENSOR_TYPES = { 'total_cameras': ['Arlo Cameras', None, 'video'], 'captured_today': ['Captured Today', None, 'file-video'], 'battery_level': ['Battery Level', '%', 'battery-50'], - 'signal_strength': ['Signal Strength', None, 'signal'] + 'signal_strength': ['Signal Strength', None, 'signal'], + 'temperature': ['Temperature', TEMP_CELSIUS, 'thermometer'], + 'humidity': ['Humidity', '%', 'water-percent'], + 'air_quality': ['Air Quality', 'ppm', 'biohazard'] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -41,7 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Arlo IP sensor.""" arlo = hass.data.get(DATA_ARLO) if not arlo: - return False + return sensors = [] for sensor_type in config.get(CONF_MONITORED_CONDITIONS): @@ -50,10 +56,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) else: for camera in arlo.cameras: + if sensor_type == 'temperature' or \ + sensor_type == 'humidity' or \ + sensor_type == 'air_quality': + continue + name = '{0} {1}'.format( SENSOR_TYPES[sensor_type][0], camera.name) sensors.append(ArloSensor(name, camera, sensor_type)) + for base_station in arlo.base_stations: + if ((sensor_type == 'temperature' or + sensor_type == 'humidity' or + sensor_type == 'air_quality') and + base_station.model_id == 'ABC1000'): + name = '{0} {1}'.format( + SENSOR_TYPES[sensor_type][0], base_station.name) + sensors.append(ArloSensor(name, base_station, sensor_type)) + add_devices(sensors, True) @@ -62,6 +82,7 @@ class ArloSensor(Entity): def __init__(self, name, device, sensor_type): """Initialize an Arlo sensor.""" + _LOGGER.debug('ArloSensor created for %s', name) self._name = name self._data = device self._sensor_type = sensor_type @@ -101,6 +122,15 @@ class ArloSensor(Entity): """Return the units of measurement.""" return SENSOR_TYPES.get(self._sensor_type)[1] + @property + def device_class(self): + """Return the device class of the sensor.""" + if self._sensor_type == 'temperature': + return DEVICE_CLASS_TEMPERATURE + elif self._sensor_type == 'humidity': + return DEVICE_CLASS_HUMIDITY + return None + def update(self): """Get the latest data and updates the state.""" _LOGGER.debug("Updating Arlo sensor %s", self.name) @@ -133,6 +163,24 @@ class ArloSensor(Entity): except TypeError: self._state = None + elif self._sensor_type == 'temperature': + try: + self._state = self._data.ambient_temperature + except TypeError: + self._state = None + + elif self._sensor_type == 'humidity': + try: + self._state = self._data.ambient_humidity + except TypeError: + self._state = None + + elif self._sensor_type == 'air_quality': + try: + self._state = self._data.ambient_air_quality + except TypeError: + self._state = None + @property def device_state_attributes(self): """Return the device state attributes.""" @@ -141,10 +189,7 @@ class ArloSensor(Entity): attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION attrs['brand'] = DEFAULT_BRAND - if self._sensor_type == 'last_capture' or \ - self._sensor_type == 'captured_today' or \ - self._sensor_type == 'battery_level' or \ - self._sensor_type == 'signal_strength': + if self._sensor_type != 'total_cameras': attrs['model'] = self._data.model_id return attrs diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 0db06622ad8..7c492fd496d 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -4,9 +4,9 @@ Support for deCONZ sensor. For more details about this component, please refer to the documentation at https://home-assistant.io/components/sensor.deconz/ """ -from homeassistant.components.deconz import ( - CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, - DATA_DECONZ_UNSUB) +from homeassistant.components.deconz.const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) from homeassistant.core import callback @@ -72,7 +72,8 @@ class DeconzSensor(Entity): """ if reason['state'] or \ 'reachable' in reason['attr'] or \ - 'battery' in reason['attr']: + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: self.async_schedule_update_ha_state() @property @@ -122,8 +123,10 @@ class DeconzSensor(Entity): attr = {} if self._sensor.battery: attr[ATTR_BATTERY_LEVEL] = self._sensor.battery + if self._sensor.on is not None: + attr[ATTR_ON] = self._sensor.on if self._sensor.type in LIGHTLEVEL and self._sensor.dark is not None: - attr['dark'] = self._sensor.dark + attr[ATTR_DARK] = self._sensor.dark if self.unit_of_measurement == 'Watts': attr[ATTR_CURRENT] = self._sensor.current attr[ATTR_VOLTAGE] = self._sensor.voltage diff --git a/homeassistant/components/sensor/duke_energy.py b/homeassistant/components/sensor/duke_energy.py new file mode 100644 index 00000000000..458a2929d0b --- /dev/null +++ b/homeassistant/components/sensor/duke_energy.py @@ -0,0 +1,84 @@ +""" +Support for Duke Energy Gas and Electric meters. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.duke_energy/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pydukeenergy==0.0.6'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + +LAST_BILL_USAGE = "last_bills_usage" +LAST_BILL_AVERAGE_USAGE = "last_bills_average_usage" +LAST_BILL_DAYS_BILLED = "last_bills_days_billed" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup all Duke Energy meters.""" + from pydukeenergy.api import DukeEnergy, DukeEnergyException + + try: + duke = DukeEnergy(config[CONF_USERNAME], + config[CONF_PASSWORD], + update_interval=120) + except DukeEnergyException: + _LOGGER.error("Failed to setup Duke Energy") + return + + add_devices([DukeEnergyMeter(meter) for meter in duke.get_meters()]) + + +class DukeEnergyMeter(Entity): + """Representation of a Duke Energy meter.""" + + def __init__(self, meter): + """Initialize the meter.""" + self.duke_meter = meter + + @property + def name(self): + """Return the name.""" + return "duke_energy_{}".format(self.duke_meter.id) + + @property + def unique_id(self): + """Return the unique ID.""" + return self.duke_meter.id + + @property + def state(self): + """Return yesterdays usage.""" + return self.duke_meter.get_usage() + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self.duke_meter.get_unit() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + LAST_BILL_USAGE: self.duke_meter.get_total(), + LAST_BILL_AVERAGE_USAGE: self.duke_meter.get_average(), + LAST_BILL_DAYS_BILLED: self.duke_meter.get_days_billed() + } + return attributes + + def update(self): + """Update meter.""" + self.duke_meter.update() diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index c14a33dce01..b9fe2941463 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -5,12 +5,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.efergy/ """ import logging + +import requests import voluptuous as vol -from requests import RequestException, get - -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_CURRENCY +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,6 @@ CONF_UTC_OFFSET = 'utc_offset' CONF_MONITORED_VARIABLES = 'monitored_variables' CONF_SENSOR_TYPE = 'type' -CONF_CURRENCY = 'currency' CONF_PERIOD = 'period' CONF_INSTANT = 'instant_readings' @@ -60,17 +60,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Efergy sensor.""" app_token = config.get(CONF_APPTOKEN) utc_offset = str(config.get(CONF_UTC_OFFSET)) + dev = [] for variable in config[CONF_MONITORED_VARIABLES]: if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES: - url_string = _RESOURCE + 'getCurrentValuesSummary?token=' \ - + app_token - response = get(url_string, timeout=10) + url_string = '{}getCurrentValuesSummary?token={}'.format( + _RESOURCE, app_token) + response = requests.get(url_string, timeout=10) for sensor in response.json(): sid = sensor['sid'] - dev.append(EfergySensor(variable[CONF_SENSOR_TYPE], app_token, - utc_offset, variable[CONF_PERIOD], - variable[CONF_CURRENCY], sid)) + dev.append(EfergySensor( + variable[CONF_SENSOR_TYPE], app_token, utc_offset, + variable[CONF_PERIOD], variable[CONF_CURRENCY], sid)) dev.append(EfergySensor( variable[CONF_SENSOR_TYPE], app_token, utc_offset, variable[CONF_PERIOD], variable[CONF_CURRENCY])) @@ -86,7 +87,7 @@ class EfergySensor(Entity): """Initialize the sensor.""" self.sid = sid if sid: - self._name = 'efergy_' + sid + self._name = 'efergy_{}'.format(sid) else: self._name = SENSOR_TYPES[sensor_type][0] self.type = sensor_type @@ -96,7 +97,8 @@ class EfergySensor(Entity): self.period = period self.currency = currency if self.type == 'cost': - self._unit_of_measurement = self.currency + '/' + self.period + self._unit_of_measurement = '{}/{}'.format( + self.currency, self.period) else: self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -119,34 +121,34 @@ class EfergySensor(Entity): """Get the Efergy monitor data from the web service.""" try: if self.type == 'instant_readings': - url_string = _RESOURCE + 'getInstant?token=' + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getInstant?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) self._state = response.json()['reading'] elif self.type == 'amount': - url_string = _RESOURCE + 'getEnergy?token=' + self.app_token \ - + '&offset=' + self.utc_offset + '&period=' \ - + self.period - response = get(url_string, timeout=10) + url_string = '{}getEnergy?token={}&offset={}&period={}'.format( + _RESOURCE, self.app_token, self.utc_offset, self.period) + response = requests.get(url_string, timeout=10) self._state = response.json()['sum'] elif self.type == 'budget': - url_string = _RESOURCE + 'getBudget?token=' + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getBudget?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) self._state = response.json()['status'] elif self.type == 'cost': - url_string = _RESOURCE + 'getCost?token=' + self.app_token \ - + '&offset=' + self.utc_offset + '&period=' \ - + self.period - response = get(url_string, timeout=10) + url_string = '{}getCost?token={}&offset={}&period={}'.format( + _RESOURCE, self.app_token, self.utc_offset, self.period) + response = requests.get(url_string, timeout=10) self._state = response.json()['sum'] elif self.type == 'current_values': - url_string = _RESOURCE + 'getCurrentValuesSummary?token=' \ - + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getCurrentValuesSummary?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) for sensor in response.json(): if self.sid == sensor['sid']: measurement = next(iter(sensor['data'][0].values())) self._state = measurement else: - self._state = 'Unknown' - except (RequestException, ValueError, KeyError): + self._state = None + except (requests.RequestException, ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 9c05028b394..261f6e2b510 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -28,6 +28,7 @@ import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) +FILTER_NAME_RANGE = 'range' FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' FILTER_NAME_THROTTLE = 'throttle' @@ -40,6 +41,8 @@ CONF_FILTER_WINDOW_SIZE = 'window_size' CONF_FILTER_PRECISION = 'precision' CONF_FILTER_RADIUS = 'radius' CONF_FILTER_TIME_CONSTANT = 'time_constant' +CONF_FILTER_LOWER_BOUND = 'lower_bound' +CONF_FILTER_UPPER_BOUND = 'upper_bound' CONF_TIME_SMA_TYPE = 'type' TIME_SMA_LAST = 'last' @@ -77,6 +80,12 @@ FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), }) +FILTER_RANGE_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_RANGE, + vol.Optional(CONF_FILTER_LOWER_BOUND): vol.Coerce(float), + vol.Optional(CONF_FILTER_UPPER_BOUND): vol.Coerce(float), +}) + FILTER_TIME_SMA_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_SMA, vol.Optional(CONF_TIME_SMA_TYPE, @@ -100,7 +109,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ [vol.Any(FILTER_OUTLIER_SCHEMA, FILTER_LOWPASS_SCHEMA, FILTER_TIME_SMA_SCHEMA, - FILTER_THROTTLE_SCHEMA)]) + FILTER_THROTTLE_SCHEMA, + FILTER_RANGE_SCHEMA)]) }) @@ -325,6 +335,49 @@ class Filter(object): return new_state +@FILTERS.register(FILTER_NAME_RANGE) +class RangeFilter(Filter): + """Range filter. + + Determines if new state is in the range of upper_bound and lower_bound. + If not inside, lower or upper bound is returned instead. + + Args: + upper_bound (float): band upper bound + lower_bound (float): band lower bound + """ + + def __init__(self, entity, + lower_bound, upper_bound): + """Initialize Filter.""" + super().__init__(FILTER_NAME_RANGE, entity=entity) + self._lower_bound = lower_bound + self._upper_bound = upper_bound + self._stats_internal = Counter() + + def _filter_state(self, new_state): + """Implement the range filter.""" + if self._upper_bound and new_state.state > self._upper_bound: + + self._stats_internal['erasures_up'] += 1 + + _LOGGER.debug("Upper outlier nr. %s in %s: %s", + self._stats_internal['erasures_up'], + self._entity, new_state) + new_state.state = self._upper_bound + + elif self._lower_bound and new_state.state < self._lower_bound: + + self._stats_internal['erasures_low'] += 1 + + _LOGGER.debug("Lower outlier nr. %s in %s: %s", + self._stats_internal['erasures_low'], + self._entity, new_state) + new_state.state = self._lower_bound + + return new_state + + @FILTERS.register(FILTER_NAME_OUTLIER) class OutlierFilter(Filter): """BASIC outlier filter. diff --git a/homeassistant/components/sensor/fixer.py b/homeassistant/components/sensor/fixer.py index 3e909b7b21d..438366ae555 100644 --- a/homeassistant/components/sensor/fixer.py +++ b/homeassistant/components/sensor/fixer.py @@ -10,15 +10,14 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_BASE, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['fixerio==0.1.1'] +REQUIREMENTS = ['fixerio==1.0.0a0'] _LOGGER = logging.getLogger(__name__) -ATTR_BASE = 'Base currency' ATTR_EXCHANGE_RATE = 'Exchange rate' ATTR_TARGET = 'Target currency' @@ -33,8 +32,8 @@ ICON = 'mdi:currency-usd' SCAN_INTERVAL = timedelta(days=1) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_TARGET): cv.string, - vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -43,17 +42,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Fixer.io sensor.""" from fixerio import Fixerio, exceptions + api_key = config.get(CONF_API_KEY) name = config.get(CONF_NAME) - base = config.get(CONF_BASE) target = config.get(CONF_TARGET) try: - Fixerio(base=base, symbols=[target], secure=True).latest() + Fixerio(symbols=[target], access_key=api_key).latest() except exceptions.FixerioException: _LOGGER.error("One of the given currencies is not supported") - return False + return - data = ExchangeData(base, target) + data = ExchangeData(target, api_key) add_devices([ExchangeRateSensor(data, name, target)], True) @@ -87,10 +86,9 @@ class ExchangeRateSensor(Entity): """Return the state attributes.""" if self.data.rate is not None: return { - ATTR_BASE: self.data.rate['base'], - ATTR_TARGET: self._target, - ATTR_EXCHANGE_RATE: self.data.rate['rates'][self._target], ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_EXCHANGE_RATE: self.data.rate['rates'][self._target], + ATTR_TARGET: self._target, } @property @@ -107,16 +105,15 @@ class ExchangeRateSensor(Entity): class ExchangeData(object): """Get the latest data and update the states.""" - def __init__(self, base_currency, target_currency): + def __init__(self, target_currency, api_key): """Initialize the data object.""" from fixerio import Fixerio + self.api_key = api_key self.rate = None - self.base_currency = base_currency self.target_currency = target_currency self.exchange = Fixerio( - base=self.base_currency, symbols=[self.target_currency], - secure=True) + symbols=[self.target_currency], access_key=self.api_key) def update(self): """Get the latest data from Fixer.io.""" diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index 616144d2bc6..93e15b9cd5e 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -16,9 +16,7 @@ from homeassistant.const import CONF_NAME from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ["https://github.com/robbiet480/pygtfs/archive/" - "00546724e4bbcb3053110d844ca44e2246267dd8.zip#" - "pygtfs==0.1.3"] +REQUIREMENTS = ['pygtfs-homeassistant==0.1.3.dev0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/homematicip_cloud.py b/homeassistant/components/sensor/homematicip_cloud.py index ccd1949cc3b..87021e9c7c5 100644 --- a/homeassistant/components/sensor/homematicip_cloud.py +++ b/homeassistant/components/sensor/homematicip_cloud.py @@ -8,8 +8,8 @@ https://home-assistant.io/components/sensor.homematicip_cloud/ import logging from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) from homeassistant.const import ( TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE) @@ -24,27 +24,21 @@ ATTR_TEMPERATURE = 'temperature' ATTR_TEMPERATURE_OFFSET = 'temperature_offset' ATTR_HUMIDITY = 'humidity' -HMIP_UPTODATE = 'up_to_date' -HMIP_VALVE_DONE = 'adaption_done' -HMIP_SABOTAGE = 'sabotage' - -STATE_OK = 'ok' -STATE_LOW_BATTERY = 'low_battery' -STATE_SABOTAGE = 'sabotage' - async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HomematicIP sensors devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP sensors from a config entry.""" from homematicip.device import ( HeatingThermostat, TemperatureHumiditySensorWithoutDisplay, TemperatureHumiditySensorDisplay, MotionDetectorIndoor) - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [HomematicipAccesspointStatus(home)] - for device in home.devices: if isinstance(device, HeatingThermostat): devices.append(HomematicipHeatingThermostat(home, device)) @@ -81,44 +75,17 @@ class HomematicipAccesspointStatus(HomematicipGenericDevice): """Device available.""" return self._home.connected + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return '%' + @property def device_state_attributes(self): """Return the state attributes of the access point.""" return {} -class HomematicipDeviceStatus(HomematicipGenericDevice): - """Representation of an HomematicIP device status.""" - - def __init__(self, home, device): - """Initialize generic status device.""" - super().__init__(home, device, 'Status') - - @property - def icon(self): - """Return the icon of the status device.""" - if (hasattr(self._device, 'sabotage') and - self._device.sabotage == HMIP_SABOTAGE): - return 'mdi:alert' - elif self._device.lowBat: - return 'mdi:battery-outline' - elif self._device.updateState.lower() != HMIP_UPTODATE: - return 'mdi:refresh' - return 'mdi:check' - - @property - def state(self): - """Return the state of the generic device.""" - if (hasattr(self._device, 'sabotage') and - self._device.sabotage == HMIP_SABOTAGE): - return STATE_SABOTAGE - elif self._device.lowBat: - return STATE_LOW_BATTERY - elif self._device.updateState.lower() != HMIP_UPTODATE: - return self._device.updateState.lower() - return STATE_OK - - class HomematicipHeatingThermostat(HomematicipGenericDevice): """MomematicIP heating thermostat representation.""" @@ -129,15 +96,19 @@ class HomematicipHeatingThermostat(HomematicipGenericDevice): @property def icon(self): """Return the icon.""" - if self._device.valveState.lower() != HMIP_VALVE_DONE: + from homematicip.base.enums import ValveState + + if self._device.valveState != ValveState.ADAPTION_DONE: return 'mdi:alert' return 'mdi:radiator' @property def state(self): """Return the state of the radiator valve.""" - if self._device.valveState.lower() != HMIP_VALVE_DONE: - return self._device.valveState.lower() + from homematicip.base.enums import ValveState + + if self._device.valveState != ValveState.ADAPTION_DONE: + return self._device.valveState return round(self._device.valvePosition*100) @property @@ -158,11 +129,6 @@ class HomematicipHumiditySensor(HomematicipGenericDevice): """Return the device class of the sensor.""" return DEVICE_CLASS_HUMIDITY - @property - def icon(self): - """Return the icon.""" - return 'mdi:water-percent' - @property def state(self): """Return the state.""" @@ -186,11 +152,6 @@ class HomematicipTemperatureSensor(HomematicipGenericDevice): """Return the device class of the sensor.""" return DEVICE_CLASS_TEMPERATURE - @property - def icon(self): - """Return the icon.""" - return 'mdi:thermometer' - @property def state(self): """Return the state.""" diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 191e587feaf..54b095bb84b 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.netatmo/ """ import logging -from datetime import timedelta +from time import time +import threading import voluptuous as vol @@ -14,7 +15,6 @@ from homeassistant.const import ( TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -24,8 +24,8 @@ CONF_STATION = 'station' DEPENDENCIES = ['netatmo'] -# NetAtmo Data is uploaded to server every 10 minutes -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=600) +# This is the NetAtmo data upload interval in seconds +NETATMO_UPDATE_INTERVAL = 600 SENSOR_TYPES = { 'temperature': ['Temperature', TEMP_CELSIUS, None, @@ -50,7 +50,7 @@ SENSOR_TYPES = { 'rf_status': ['Radio', '', 'mdi:signal', None], 'rf_status_lvl': ['Radio_lvl', '', 'mdi:signal', None], 'wifi_status': ['Wifi', '', 'mdi:wifi', None], - 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi', None] + 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi', None], } MODULE_SCHEMA = vol.Schema({ @@ -76,11 +76,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # Iterate each module for module_name, monitored_conditions in\ config[CONF_MODULES].items(): - # Test if module exist """ + # Test if module exists if module_name not in data.get_module_names(): _LOGGER.error('Module name: "%s" not found', module_name) continue - # Only create sensor for monitored """ + # Only create sensors for monitored properties for variable in monitored_conditions: dev.append(NetAtmoSensor(data, module_name, variable)) else: @@ -296,20 +296,57 @@ class NetAtmoData(object): self.data = None self.station_data = None self.station = station + self._next_update = time() + self._update_in_progress = threading.Lock() def get_module_names(self): """Return all module available on the API as a list.""" self.update() return self.data.keys() - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Call the Netatmo API to update the data.""" - import pyatmo - self.station_data = pyatmo.WeatherStationData(self.auth) + """Call the Netatmo API to update the data. - if self.station is not None: - self.data = self.station_data.lastData( - station=self.station, exclude=3600) - else: - self.data = self.station_data.lastData(exclude=3600) + This method is not throttled by the builtin Throttle decorator + but with a custom logic, which takes into account the time + of the last update from the cloud. + """ + if time() < self._next_update or \ + not self._update_in_progress.acquire(False): + return + + try: + import pyatmo + self.station_data = pyatmo.WeatherStationData(self.auth) + + if self.station is not None: + self.data = self.station_data.lastData( + station=self.station, exclude=3600) + else: + self.data = self.station_data.lastData(exclude=3600) + + newinterval = 0 + for module in self.data: + if 'When' in self.data[module]: + newinterval = self.data[module]['When'] + break + if newinterval: + # Try and estimate when fresh data will be available + newinterval += NETATMO_UPDATE_INTERVAL - time() + if newinterval > NETATMO_UPDATE_INTERVAL - 30: + newinterval = NETATMO_UPDATE_INTERVAL + else: + if newinterval < NETATMO_UPDATE_INTERVAL / 2: + # Never hammer the NetAtmo API more than + # twice per update interval + newinterval = NETATMO_UPDATE_INTERVAL / 2 + _LOGGER.warning( + "NetAtmo refresh interval reset to %d seconds", + newinterval) + else: + # Last update time not found, fall back to default value + newinterval = NETATMO_UPDATE_INTERVAL + + self._next_update = time() + newinterval + finally: + self._update_in_progress.release() diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index bf440728a2e..7c7ff3480b0 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -107,6 +107,20 @@ SENSOR_TYPES = { ['Voltage Transfer Reason', '', 'mdi:information-outline'], 'input.voltage': ['Input Voltage', 'V', 'mdi:flash'], 'input.voltage.nominal': ['Nominal Input Voltage', 'V', 'mdi:flash'], + 'input.frequency': ['Input Line Frequency', 'hz', 'mdi:flash'], + 'input.frequency.nominal': + ['Nominal Input Line Frequency', 'hz', 'mdi:flash'], + 'input.frequency.status': + ['Input Frequency Status', '', 'mdi:information-outline'], + 'output.current': ['Output Current', 'A', 'mdi:flash'], + 'output.current.nominal': + ['Nominal Output Current', 'A', 'mdi:flash'], + 'output.voltage': ['Output Voltage', 'V', 'mdi:flash'], + 'output.voltage.nominal': + ['Nominal Output Voltage', 'V', 'mdi:flash'], + 'output.frequency': ['Output Frequency', 'hz', 'mdi:flash'], + 'output.frequency.nominal': + ['Nominal Output Frequency', 'hz', 'mdi:flash'], } STATE_TYPES = { diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 838358fcfca..c11c83ab40e 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -183,9 +183,12 @@ class PollencomSensor(Entity): return if self._category: - data = self.pollencom.data[self._category]['Location'] + data = self.pollencom.data[self._category].get('Location') else: - data = self.pollencom.data[self._type]['Location'] + data = self.pollencom.data[self._type].get('Location') + + if not data: + return indices = [p['Index'] for p in data['periods']] average = round(mean(indices), 1) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 7fefb0f450b..8574a7231da 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.8'] +REQUIREMENTS = ['sqlalchemy==1.2.9'] CONF_COLUMN_NAME = 'column' CONF_QUERIES = 'queries' diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index a0198169b6d..e3c3a0cf5ca 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -12,18 +12,20 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, TEMP_CELSIUS, - CONF_MONITORED_CONDITIONS, EVENT_HOMEASSISTANT_START, CONF_DISKS) + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, + ATTR_ATTRIBUTION, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, + EVENT_HOMEASSISTANT_START, CONF_DISKS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['python-synology==0.1.0'] +REQUIREMENTS = ['python-synology==0.2.0'] _LOGGER = logging.getLogger(__name__) +CONF_ATTRIBUTION = 'Data provided by Synology' CONF_VOLUMES = 'volumes' DEFAULT_NAME = 'Synology DSM' -DEFAULT_PORT = 5000 +DEFAULT_PORT = 5001 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -74,6 +76,7 @@ _MONITORED_CONDITIONS = list(_UTILISATION_MON_COND.keys()) + \ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=True): cv.boolean, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): @@ -95,10 +98,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = config.get(CONF_PORT) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) + use_ssl = config.get(CONF_SSL) unit = hass.config.units.temperature_unit monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) - api = SynoApi(host, port, username, password, unit) + api = SynoApi(host, port, username, password, unit, use_ssl) sensors = [SynoNasUtilSensor( api, variable, _UTILISATION_MON_COND[variable]) @@ -128,13 +132,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class SynoApi(object): """Class to interface with Synology DSM API.""" - def __init__(self, host, port, username, password, temp_unit): + def __init__(self, host, port, username, password, temp_unit, use_ssl): """Initialize the API wrapper class.""" from SynologyDSM import SynologyDSM self.temp_unit = temp_unit try: - self._api = SynologyDSM(host, port, username, password) + self._api = SynologyDSM(host, port, username, password, + use_https=use_ssl) except: # noqa: E722 # pylint: disable=bare-except _LOGGER.error("Error setting up Synology DSM") @@ -185,6 +190,13 @@ class SynoNasSensor(Entity): if self._api is not None: self._api.update() + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + class SynoNasUtilSensor(SynoNasSensor): """Representation a Synology Utilisation Sensor.""" diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 42568a6b9ad..c75c40dd929 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -123,7 +123,7 @@ class TibberSensor(Entity): async def _fetch_data(self): try: await self._tibber_home.update_info() - await self._tibber_home.update_price_info() + await self._tibber_home.update_price_info() except (asyncio.TimeoutError, aiohttp.ClientError): return data = self._tibber_home.info['viewer']['home'] diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index fc40d17d0af..0b059379c11 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -18,7 +18,7 @@ import homeassistant.helpers.location as location from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['WazeRouteCalculator==0.5'] +REQUIREMENTS = ['WazeRouteCalculator==0.6'] _LOGGER = logging.getLogger(__name__) @@ -40,6 +40,8 @@ REGIONS = ['US', 'NA', 'EU', 'IL'] SCAN_INTERVAL = timedelta(minutes=5) +TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone'] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, @@ -49,8 +51,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_EXCL_FILTER): cv.string, }) -TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone'] - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Waze travel time sensor platform.""" @@ -72,10 +72,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def _get_location_from_attributes(state): """Get the lat/long string from an states attributes.""" attr = state.attributes - return '{},{}'.format( - attr.get(ATTR_LATITUDE), - attr.get(ATTR_LONGITUDE) - ) + return '{},{}'.format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) class WazeTravelTime(Entity): @@ -186,13 +183,11 @@ class WazeTravelTime(Entity): if self._origin_entity_id is not None: self._origin = self._get_location_from_entity( - self._origin_entity_id - ) + self._origin_entity_id) if self._destination_entity_id is not None: self._destination = self._get_location_from_entity( - self._destination_entity_id - ) + self._destination_entity_id) self._destination = self._resolve_zone(self._destination) self._origin = self._resolve_zone(self._origin) @@ -217,7 +212,8 @@ class WazeTravelTime(Entity): self._state = { 'duration': duration, 'distance': distance, - 'route': route} + 'route': route, + } except WazeRouteCalculator.WRCError as exp: _LOGGER.error("Error on retrieving data: %s", exp) return diff --git a/homeassistant/components/sensor/wirelesstag.py b/homeassistant/components/sensor/wirelesstag.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index bab2abbad0d..b9ee8126ed3 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -95,7 +95,7 @@ def toggle(hass, entity_id=None): async def async_setup(hass, config): """Track states and offer events for switches.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES) await component.async_setup(config) @@ -132,6 +132,16 @@ async def async_setup(hass, config): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class SwitchDevice(ToggleEntity): """Representation of a switch.""" diff --git a/homeassistant/components/switch/amcrest.py b/homeassistant/components/switch/amcrest.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/switch/enocean.py b/homeassistant/components/switch/enocean.py index abe197485d4..986744aeec1 100644 --- a/homeassistant/components/switch/enocean.py +++ b/homeassistant/components/switch/enocean.py @@ -18,10 +18,12 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'EnOcean Switch' DEPENDENCIES = ['enocean'] +CONF_CHANNEL = 'channel' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CHANNEL, default=0): cv.positive_int, }) @@ -29,14 +31,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the EnOcean switch platform.""" dev_id = config.get(CONF_ID) devname = config.get(CONF_NAME) + channel = config.get(CONF_CHANNEL) - add_devices([EnOceanSwitch(dev_id, devname)]) + add_devices([EnOceanSwitch(dev_id, devname, channel)]) class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): """Representation of an EnOcean switch device.""" - def __init__(self, dev_id, devname): + def __init__(self, dev_id, devname, channel): """Initialize the EnOcean switch device.""" enocean.EnOceanDevice.__init__(self) self.dev_id = dev_id @@ -44,6 +47,7 @@ class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): self._light = None self._on_state = False self._on_state2 = False + self.channel = channel self.stype = "switch" @property @@ -61,7 +65,7 @@ class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): optional = [0x03, ] optional.extend(self.dev_id) optional.extend([0xff, 0x00]) - self.send_command(data=[0xD2, 0x01, 0x00, 0x64, 0x00, + self.send_command(data=[0xD2, 0x01, self.channel & 0xFF, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00], optional=optional, packet_type=0x01) self._on_state = True @@ -71,7 +75,7 @@ class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): optional = [0x03, ] optional.extend(self.dev_id) optional.extend([0xff, 0x00]) - self.send_command(data=[0xD2, 0x01, 0x00, 0x00, 0x00, + self.send_command(data=[0xD2, 0x01, self.channel & 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], optional=optional, packet_type=0x01) self._on_state = False diff --git a/homeassistant/components/switch/eufy.py b/homeassistant/components/switch/eufy.py index 891525d3979..7320ea8d557 100644 --- a/homeassistant/components/switch/eufy.py +++ b/homeassistant/components/switch/eufy.py @@ -25,7 +25,6 @@ class EufySwitch(SwitchDevice): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error import lakeside self._state = None diff --git a/homeassistant/components/switch/fritzbox.py b/homeassistant/components/switch/fritzbox.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/switch/homematicip_cloud.py b/homeassistant/components/switch/homematicip_cloud.py index 9123d46c87b..68884aaaa02 100644 --- a/homeassistant/components/switch/homematicip_cloud.py +++ b/homeassistant/components/switch/homematicip_cloud.py @@ -9,8 +9,8 @@ import logging from homeassistant.components.switch import SwitchDevice from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) DEPENDENCIES = ['homematicip_cloud'] @@ -24,13 +24,16 @@ ATTR_PROFILE_MODE = 'profile_mode' async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HomematicIP switch devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP switch from a config entry.""" from homematicip.device import ( PlugableSwitch, PlugableSwitchMeasuring, BrandSwitchMeasuring) - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: if isinstance(device, BrandSwitchMeasuring): diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py index 42b4829f64e..c357d1ccc04 100644 --- a/homeassistant/components/switch/insteon_plm.py +++ b/homeassistant/components/switch/insteon_plm.py @@ -46,8 +46,7 @@ class InsteonPLMSwitchDevice(InsteonPLMEntity, SwitchDevice): @property def is_on(self): """Return the boolean response if the node is on.""" - onlevel = self._insteon_device_state.value - return bool(onlevel) + return bool(self._insteon_device_state.value) @asyncio.coroutine def async_turn_on(self, **kwargs): @@ -63,6 +62,11 @@ class InsteonPLMSwitchDevice(InsteonPLMEntity, SwitchDevice): class InsteonPLMOpenClosedDevice(InsteonPLMEntity, SwitchDevice): """A Class for an Insteon device.""" + @property + def is_on(self): + """Return the boolean response if the node is on.""" + return bool(self._insteon_device_state.value) + @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn device on.""" diff --git a/homeassistant/components/switch/tuya.py b/homeassistant/components/switch/tuya.py new file mode 100644 index 00000000000..4f69e76f954 --- /dev/null +++ b/homeassistant/components/switch/tuya.py @@ -0,0 +1,47 @@ +""" +Support for Tuya switch. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.tuya/ +""" +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice + +DEPENDENCIES = ['tuya'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya Switch device.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaSwitch(device)) + add_devices(devices) + + +class TuyaSwitch(TuyaDevice, SwitchDevice): + """Tuya Switch Device.""" + + def __init__(self, tuya): + """Init Tuya switch device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + + @property + def is_on(self): + """Return true if switch is on.""" + return self.tuya.state() + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.tuya.turn_on() + + def turn_off(self, **kwargs): + """Turn the device off.""" + self.tuya.turn_off() diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 84edd9afd40..ba91dd7c1fc 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -40,6 +40,8 @@ TAHOMA_TYPES = { 'rts:CurtainRTSComponent': 'cover', 'rts:BlindRTSComponent': 'cover', 'rts:VenetianBlindRTSComponent': 'cover', + 'rts:DualCurtainRTSComponent': 'cover', + 'rts:ExteriorVenetianBlindRTSComponent': 'cover', 'io:ExteriorVenetianBlindIOComponent': 'cover', 'io:RollerShutterUnoIOComponent': 'cover', 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index 46c1a24caa0..d59331984b7 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -38,7 +38,8 @@ SUPPORTED_VOICES = ['Geraint', 'Gwyneth', 'Mads', 'Naja', 'Hans', 'Marlene', 'Chantal', 'Celine', 'Mathieu', 'Dora', 'Karl', 'Carla', 'Giorgio', 'Mizuki', 'Liv', 'Lotte', 'Ruben', 'Ewa', 'Jacek', 'Jan', 'Maja', 'Ricardo', 'Vitoria', 'Cristiano', - 'Ines', 'Carmen', 'Maxim', 'Tatyana', 'Astrid', 'Filiz'] + 'Ines', 'Carmen', 'Maxim', 'Tatyana', 'Astrid', 'Filiz', + 'Aditi', 'Léa', 'Matthew', 'Seoyeon', 'Takumi', 'Vicki'] SUPPORTED_OUTPUT_FORMATS = ['mp3', 'ogg_vorbis', 'pcm'] diff --git a/homeassistant/components/tuya.py b/homeassistant/components/tuya.py new file mode 100644 index 00000000000..c557774b5f1 --- /dev/null +++ b/homeassistant/components/tuya.py @@ -0,0 +1,166 @@ +""" +Support for Tuya Smart devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tuya/ +""" +from datetime import timedelta +import logging +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import ( + dispatcher_send, async_dispatcher_connect) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['tuyapy==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_COUNTRYCODE = 'country_code' + +DOMAIN = 'tuya' +DATA_TUYA = 'data_tuya' + +SIGNAL_DELETE_ENTITY = 'tuya_delete' +SIGNAL_UPDATE_ENTITY = 'tuya_update' + +SERVICE_FORCE_UPDATE = 'force_update' +SERVICE_PULL_DEVICES = 'pull_devices' + +TUYA_TYPE_TO_HA = { + 'light': 'light', + 'switch': 'switch', +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_COUNTRYCODE): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up Tuya Component.""" + from tuyapy import TuyaApi + + tuya = TuyaApi() + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + country_code = config[DOMAIN][CONF_COUNTRYCODE] + + hass.data[DATA_TUYA] = tuya + tuya.init(username, password, country_code) + hass.data[DOMAIN] = { + 'entities': {} + } + + def load_devices(device_list): + """Load new devices by device_list.""" + device_type_list = {} + for device in device_list: + dev_type = device.device_type() + if (dev_type in TUYA_TYPE_TO_HA and + device.object_id() not in hass.data[DOMAIN]['entities']): + ha_type = TUYA_TYPE_TO_HA[dev_type] + if ha_type not in device_type_list: + device_type_list[ha_type] = [] + device_type_list[ha_type].append(device.object_id()) + hass.data[DOMAIN]['entities'][device.object_id()] = None + for ha_type, dev_ids in device_type_list.items(): + discovery.load_platform( + hass, ha_type, DOMAIN, {'dev_ids': dev_ids}, config) + + device_list = tuya.get_all_devices() + load_devices(device_list) + + def poll_devices_update(event_time): + """Check if accesstoken is expired and pull device list from server.""" + _LOGGER.debug("Pull devices from Tuya.") + tuya.poll_devices_update() + # Add new discover device. + device_list = tuya.get_all_devices() + load_devices(device_list) + # Delete not exist device. + newlist_ids = [] + for device in device_list: + newlist_ids.append(device.object_id()) + for dev_id in list(hass.data[DOMAIN]['entities']): + if dev_id not in newlist_ids: + dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id) + hass.data[DOMAIN]['entities'].pop(dev_id) + + track_time_interval(hass, poll_devices_update, timedelta(minutes=5)) + + hass.services.register(DOMAIN, SERVICE_PULL_DEVICES, poll_devices_update) + + def force_update(call): + """Force all devices to pull data.""" + dispatcher_send(hass, SIGNAL_UPDATE_ENTITY) + + hass.services.register(DOMAIN, SERVICE_FORCE_UPDATE, force_update) + + return True + + +class TuyaDevice(Entity): + """Tuya base device.""" + + def __init__(self, tuya): + """Init Tuya devices.""" + self.tuya = tuya + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + dev_id = self.tuya.object_id() + self.hass.data[DOMAIN]['entities'][dev_id] = self.entity_id + async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback) + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback) + + @property + def object_id(self): + """Return Tuya device id.""" + return self.tuya.object_id() + + @property + def unique_id(self): + """Return a unique ID.""" + return 'tuya.{}'.format(self.tuya.object_id()) + + @property + def name(self): + """Return Tuya device name.""" + return self.tuya.name() + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self.tuya.iconurl() + + @property + def available(self): + """Return if the device is available.""" + return self.tuya.available() + + def update(self): + """Refresh Tuya device data.""" + self.tuya.update() + + @callback + def _delete_callback(self, dev_id): + """Remove this entity.""" + if dev_id == self.object_id: + self.hass.async_add_job(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 1b7d5685231..880b3604a86 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -57,7 +57,7 @@ VACUUM_SET_FAN_SPEED_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ VACUUM_SEND_COMMAND_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ vol.Required(ATTR_COMMAND): cv.string, - vol.Optional(ATTR_PARAMS): vol.Any(cv.Dict, cv.ensure_list), + vol.Optional(ATTR_PARAMS): vol.Any(dict, cv.ensure_list), }) SERVICE_TO_METHOD = { diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index 44d22e03f41..750c2c0ae0a 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -284,7 +284,9 @@ class RoombaVacuum(VacuumDevice): software_version = state.get('softwareVer') # Error message in plain english - error_msg = self.vacuum.error_message + error_msg = 'None' + if hasattr(self.vacuum, 'error_message'): + error_msg = self.vacuum.error_message self._battery_level = state.get('batPct') self._status = self.vacuum.current_state diff --git a/homeassistant/components/watson_iot.py b/homeassistant/components/watson_iot.py index 246cf3a96c2..889984eb223 100644 --- a/homeassistant/components/watson_iot.py +++ b/homeassistant/components/watson_iot.py @@ -4,7 +4,6 @@ A component which allows you to send data to the IBM Watson IoT Platform. For more details about this component, please refer to the documentation at https://home-assistant.io/components/watson_iot/ """ - import logging import queue import threading @@ -13,8 +12,8 @@ import time import voluptuous as vol from homeassistant.const import ( - CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, - CONF_TOKEN, CONF_TYPE, EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_STOP, + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ID, CONF_INCLUDE, + CONF_TOKEN, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN) from homeassistant.helpers import state as state_helper import homeassistant.helpers.config_validation as cv @@ -24,13 +23,13 @@ REQUIREMENTS = ['ibmiotf==0.3.4'] _LOGGER = logging.getLogger(__name__) CONF_ORG = 'organization' -CONF_ID = 'id' DOMAIN = 'watson_iot' -RETRY_DELAY = 20 MAX_TRIES = 3 +RETRY_DELAY = 20 + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All(vol.Schema({ vol.Required(CONF_ORG): cv.string, @@ -103,7 +102,7 @@ def setup(hass, config): }, 'time': event.time_fired.isoformat(), 'fields': { - 'state': state.state + 'state': state.state, } } if _state_as_value is not None: @@ -113,7 +112,7 @@ def setup(hass, config): if key != 'unit_of_measurement': # If the key is already in fields if key in out_event['fields']: - key = key + "_" + key = '{}_'.format(key) # For each value we try to cast it as float # But if we can not do it we store the value # as string @@ -153,7 +152,7 @@ class WatsonIOTThread(threading.Thread): hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) def _event_listener(self, event): - """Listen for new messages on the bus and queue them for Watson IOT.""" + """Listen for new messages on the bus and queue them for Watson IoT.""" item = (time.monotonic(), event) self.queue.put(item) @@ -191,7 +190,7 @@ class WatsonIOTThread(threading.Thread): field, 'json', value) if not device_success: _LOGGER.error( - "Failed to publish message to watson iot") + "Failed to publish message to Watson IoT") continue break except (ibmiotf.MissingMessageEncoderException, IOError): @@ -199,7 +198,7 @@ class WatsonIOTThread(threading.Thread): time.sleep(RETRY_DELAY) else: _LOGGER.exception( - "Failed to publish message to watson iot") + "Failed to publish message to Watson IoT") def run(self): """Process incoming events.""" diff --git a/homeassistant/components/weather/openweathermap.py b/homeassistant/components/weather/openweathermap.py index 8354757ff33..65fa7c8cb0f 100644 --- a/homeassistant/components/weather/openweathermap.py +++ b/homeassistant/components/weather/openweathermap.py @@ -156,6 +156,8 @@ class OpenWeatherMapWeather(WeatherEntity): entry.get_temperature('celsius').get('day'), ATTR_FORECAST_TEMP_LOW: entry.get_temperature('celsius').get('night'), + ATTR_FORECAST_PRECIPITATION: + entry.get_rain().get('all'), ATTR_FORECAST_WIND_SPEED: entry.get_wind().get('speed'), ATTR_FORECAST_WIND_BEARING: @@ -223,12 +225,10 @@ class WeatherData(object): try: if self._mode == 'daily': fcd = self.owm.daily_forecast_at_coords( - self.latitude, self.longitude, 15 - ) + self.latitude, self.longitude, 15) else: fcd = self.owm.three_hours_forecast_at_coords( - self.latitude, self.longitude - ) + self.latitude, self.longitude) except APICallError: _LOGGER.error("Exception when calling OWM web API " "to update forecast") diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index c26f68a2c29..98e3057338a 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -7,7 +7,7 @@ https://home-assistant.io/developers/websocket_api/ import asyncio from concurrent import futures from contextlib import suppress -from functools import partial +from functools import partial, wraps import json import logging @@ -26,7 +26,8 @@ from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.auth import validate_password from homeassistant.components.http.const import KEY_AUTHENTICATED -from homeassistant.components.http.ban import process_wrong_login +from homeassistant.components.http.ban import process_wrong_login, \ + process_success_login DOMAIN = 'websocket_api' @@ -196,6 +197,23 @@ def async_register_command(hass, command, handler, schema): handlers[command] = (handler, schema) +def require_owner(func): + """Websocket decorator to require user to be an owner.""" + @wraps(func) + def with_owner(hass, connection, msg): + """Check owner and call function.""" + user = connection.request.get('hass_user') + + if user is None or not user.is_owner: + connection.to_write.put_nowait(error_message( + msg['id'], 'unauthorized', 'This command is for owners only.')) + return + + func(hass, connection, msg) + + return with_owner + + async def async_setup(hass, config): """Initialize the websocket API.""" hass.http.register_view(WebsocketAPIView) @@ -325,6 +343,8 @@ class ActiveConnection: token = self.hass.auth.async_get_access_token( msg['access_token']) authenticated = token is not None + if authenticated: + request['hass_user'] = token.refresh_token.user elif ((not self.hass.auth.active or self.hass.auth.support_legacy) and @@ -341,6 +361,7 @@ class ActiveConnection: return wsock self.debug("Auth OK") + await process_success_login(request) await self.wsock.send_json(auth_ok_message()) # ---------- AUTH PHASE OVER ---------- diff --git a/homeassistant/config.py b/homeassistant/config.py index 2906f07a307..d9206d62250 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -7,19 +7,20 @@ import os import re import shutil # pylint: disable=unused-import -from typing import Any, List, Tuple, Optional # NOQA +from typing import Any, Tuple, Optional # noqa: F401 import voluptuous as vol from voluptuous.humanize import humanize_error from homeassistant import auth +from homeassistant.auth import providers as auth_providers from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM, CONF_TIME_ZONE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, - CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS) + CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS, CONF_TYPE) from homeassistant.core import callback, DOMAIN as CONF_CORE from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import get_component, get_platform @@ -159,7 +160,12 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ vol.All(cv.ensure_list, [vol.IsDir()]), vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, vol.Optional(CONF_AUTH_PROVIDERS): - vol.All(cv.ensure_list, [auth.AUTH_PROVIDER_SCHEMA]) + vol.All(cv.ensure_list, + [auth_providers.AUTH_PROVIDER_SCHEMA.extend({ + CONF_TYPE: vol.NotIn(['insecure_example'], + 'The insecure_example auth provider' + ' is for testing only.') + })]) }) @@ -170,7 +176,8 @@ def get_default_config_dir() -> str: return os.path.join(data_dir, CONFIG_DIR_NAME) # type: ignore -def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: +def ensure_config_exists(config_dir: str, detect_location: bool = True)\ + -> Optional[str]: """Ensure a configuration file exists in given configuration directory. Creating a default one if needed. @@ -186,7 +193,8 @@ def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: return config_path -def create_default_config(config_dir, detect_location=True): +def create_default_config(config_dir: str, detect_location=True)\ + -> Optional[str]: """Create a default configuration file in given configuration directory. Return path to new config file if success, None if failed. @@ -285,11 +293,8 @@ async def async_hass_config_yaml(hass): return conf -def find_config_file(config_dir): - """Look in given directory for supported configuration files. - - Async friendly. - """ +def find_config_file(config_dir: str) -> Optional[str]: + """Look in given directory for supported configuration files.""" config_path = os.path.join(config_dir, YAML_CONFIG_FILE) return config_path if os.path.isfile(config_path) else None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index be67ebd9cc3..2e5613057f1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -127,6 +127,7 @@ HANDLERS = Registry() FLOWS = [ 'cast', 'deconz', + 'homematicip_cloud', 'hue', 'nest', 'sonos', diff --git a/homeassistant/const.py b/homeassistant/const.py index 10037718402..1627910f9bb 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 = 73 -PATCH_VERSION = '2' +MINOR_VERSION = 74 +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 e0950172913..8b534bf1731 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -17,7 +17,8 @@ import threading from time import monotonic from types import MappingProxyType -from typing import Optional, Any, Callable, List, TypeVar, Dict # NOQA +from typing import ( # NOQA + Optional, Any, Callable, List, TypeVar, Dict, Coroutine) from async_timeout import timeout import voluptuous as vol @@ -105,7 +106,7 @@ class CoreState(enum.Enum): def __str__(self) -> str: """Return the event.""" - return self.value + return self.value # type: ignore class HomeAssistant(object): @@ -136,7 +137,7 @@ class HomeAssistant(object): # This is a dictionary that any component can store any data on. self.data = {} self.state = CoreState.not_running - self.exit_code = None + self.exit_code = 0 # type: int self.config_entries = None @property @@ -205,8 +206,8 @@ class HomeAssistant(object): def async_add_job( self, target: Callable[..., Any], - *args: Any) -> Optional[asyncio.tasks.Task]: - """Add a job from within the eventloop. + *args: Any) -> Optional[asyncio.Future]: + """Add a job from within the event loop. This method must be run in the event loop. @@ -230,13 +231,29 @@ class HomeAssistant(object): return task + @callback + def async_create_task(self, target: Coroutine) -> asyncio.tasks.Task: + """Create a task from within the eventloop. + + This method must be run in the event loop. + + target: target to call. + """ + task = self.loop.create_task(target) # type: asyncio.tasks.Task + + if self._track_task: + self._pending_tasks.append(task) + + return task + @callback def async_add_executor_job( self, target: Callable[..., Any], - *args: Any) -> asyncio.tasks.Task: + *args: Any) -> asyncio.Future: """Add an executor job from within the event loop.""" - task = self.loop.run_in_executor(None, target, *args) + task = self.loop.run_in_executor( + None, target, *args) # type: asyncio.Future # If a task is scheduled if self._track_task: @@ -291,7 +308,7 @@ class HomeAssistant(object): """Stop Home Assistant and shuts down all threads.""" fire_coroutine_threadsafe(self.async_stop(), self.loop) - async def async_stop(self, exit_code=0) -> None: + async def async_stop(self, exit_code: int = 0) -> None: """Stop Home Assistant and shuts down all threads. This method is a coroutine. diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 5aa53f17e7b..4357c4109eb 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -137,7 +137,8 @@ class IntentHandler: if self._slot_schema is None: self._slot_schema = vol.Schema({ key: SLOT_SCHEMA.extend({'value': validator}) - for key, validator in self.slot_schema.items()}) + for key, validator in self.slot_schema.items()}, + extra=vol.ALLOW_EXTRA) return self._slot_schema(slots) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9114a4db941..7ab90b7a048 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,7 +1,5 @@ """Service calling related helpers.""" import logging -# pylint: disable=unused-import -from typing import Optional # NOQA from os import path import voluptuous as vol diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index f97d7051459..72deabaae28 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -27,14 +27,15 @@ from homeassistant.components.climate.ecobee import ( ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME, ATTR_RESUME_ALL, SERVICE_RESUME_PROGRAM) from homeassistant.components.cover import ( - ATTR_POSITION) + ATTR_POSITION, ATTR_TILT_POSITION) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_OPTION, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, SERVICE_LOCK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, - SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, STATE_ALARM_ARMED_AWAY, + SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_HOME, STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, @@ -68,7 +69,8 @@ SERVICE_ATTRIBUTES = { SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE], SERVICE_SEND_IR_CODE: [ATTR_IR_CODE], SERVICE_SELECT_OPTION: [ATTR_OPTION], - SERVICE_SET_COVER_POSITION: [ATTR_POSITION] + SERVICE_SET_COVER_POSITION: [ATTR_POSITION], + SERVICE_SET_COVER_TILT_POSITION: [ATTR_TILT_POSITION] } # Update this dict when new services are added to HA. diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 962074ec3af..a68b489868d 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -80,11 +80,10 @@ class Store: data = self._data else: data = await self.hass.async_add_executor_job( - json.load_json, self.path, None) + json.load_json, self.path) - if data is None: + if data == {}: return None - if data['version'] == self.version: stored = data['data'] else: diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index f1335f73346..81ec046f2e9 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -1,7 +1,5 @@ """Translation string lookup helpers.""" import logging -# pylint: disable=unused-import -from typing import Optional # NOQA from os import path from homeassistant import config_entries diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e3e41e09db2..52e6b1e7703 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -17,19 +17,19 @@ import sys from types import ModuleType # pylint: disable=unused-import -from typing import Dict, List, Optional, Sequence, Set # NOQA +from typing import Dict, List, Optional, Sequence, Set, TYPE_CHECKING # NOQA from homeassistant.const import PLATFORM_FORMAT from homeassistant.util import OrderedSet -# Typing imports +# Typing imports that create a circular dependency # pylint: disable=using-constant-test,unused-import -if False: +if TYPE_CHECKING: from homeassistant.core import HomeAssistant # NOQA PREPARED = False -DEPENDENCY_BLACKLIST = set(('config',)) +DEPENDENCY_BLACKLIST = {'config'} _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,8 @@ PATH_CUSTOM_COMPONENTS = 'custom_components' PACKAGE_COMPONENTS = 'homeassistant.components' -def set_component(hass, comp_name: str, component: ModuleType) -> None: +def set_component(hass, # type: HomeAssistant + comp_name: str, component: Optional[ModuleType]) -> None: """Set a component in the cache. Async friendly. @@ -66,7 +67,7 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: Async friendly. """ try: - return hass.data[DATA_KEY][comp_or_platform] + return hass.data[DATA_KEY][comp_or_platform] # type: ignore except KeyError: pass @@ -93,7 +94,8 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: # This prevents that when only # custom_components/switch/some_platform.py exists, # the import custom_components.switch would succeed. - if module.__spec__ and module.__spec__.origin == 'namespace': + # __file__ was unset for namespaces before Python 3.7 + if getattr(module, '__file__', None) is None: continue _LOGGER.info("Loaded %s from %s", comp_or_platform, path) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 32374b90135..66b17cf9bd9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ certifi>=2018.04.16 jinja2>=2.10 pip>=8.0.3 pytz>=2018.04 -pyyaml>=3.11,<4 +pyyaml>=3.13,<4 requests==2.19.1 voluptuous==0.11.1 diff --git a/homeassistant/remote.py b/homeassistant/remote.py index b3e5f417618..ae932b7d955 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -38,7 +38,7 @@ class APIStatus(enum.Enum): def __str__(self) -> str: """Return the state.""" - return self.value + return self.value # type: ignore class API(object): diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index dacdc7b18e2..d141faa4c27 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -1,11 +1,13 @@ """Script to manage users for the Home Assistant auth provider.""" import argparse import asyncio +import logging import os +from homeassistant.auth import auth_manager_from_config from homeassistant.core import HomeAssistant from homeassistant.config import get_default_config_dir -from homeassistant.auth_providers import homeassistant as hass_auth +from homeassistant.auth.providers import homeassistant as hass_auth def run(args): @@ -42,16 +44,28 @@ def run(args): args = parser.parse_args(args) loop = asyncio.get_event_loop() hass = HomeAssistant(loop=loop) + loop.run_until_complete(run_command(hass, args)) + + # Triggers save on used storage helpers with delay (core auth) + logging.getLogger('homeassistant.core').setLevel(logging.WARNING) + loop.run_until_complete(hass.async_stop()) + + +async def run_command(hass, args): + """Run the command.""" hass.config.config_dir = os.path.join(os.getcwd(), args.config) - data = hass_auth.Data(hass) - loop.run_until_complete(data.async_load()) - loop.run_until_complete(args.func(data, args)) + hass.auth = await auth_manager_from_config(hass, [{ + 'type': 'homeassistant', + }]) + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await args.func(hass, provider, args) -async def list_users(data, args): +async def list_users(hass, provider, args): """List the users.""" count = 0 - for user in data.users: + for user in provider.data.users: count += 1 print(user['username']) @@ -59,27 +73,33 @@ async def list_users(data, args): print("Total users:", count) -async def add_user(data, args): +async def add_user(hass, provider, args): """Create a user.""" - data.add_user(args.username, args.password) - await data.async_save() - print("User created") + try: + provider.data.add_auth(args.username, args.password) + except hass_auth.InvalidUser: + print("Username already exists!") + return + + # Save username/password + await provider.data.async_save() + print("Auth created") -async def validate_login(data, args): +async def validate_login(hass, provider, args): """Validate a login.""" try: - data.validate_login(args.username, args.password) + provider.data.validate_login(args.username, args.password) print("Auth valid") except hass_auth.InvalidAuth: print("Auth invalid") -async def change_password(data, args): +async def change_password(hass, provider, args): """Change password.""" try: - data.change_password(args.username, args.new_password) - await data.async_save() + provider.data.change_password(args.username, args.new_password) + await provider.data.async_save() print("Password changed") except hass_auth.InvalidUser: print("User not found") diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index 51d70d1f3b2..54e7eb01ae1 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==13.0.0', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==13.2.1', 'keyrings.alt==3.1'] def run(args): diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 1664653f2a7..478320dca27 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -26,7 +26,7 @@ SLOW_SETUP_WARNING = 10 def setup_component(hass: core.HomeAssistant, domain: str, config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies.""" - return run_coroutine_threadsafe( + return run_coroutine_threadsafe( # type: ignore async_setup_component(hass, domain, config), loop=hass.loop).result() @@ -42,7 +42,7 @@ async def async_setup_component(hass: core.HomeAssistant, domain: str, setup_tasks = hass.data.get(DATA_SETUP) if setup_tasks is not None and domain in setup_tasks: - return await setup_tasks[domain] + return await setup_tasks[domain] # type: ignore if config is None: config = {} @@ -50,10 +50,10 @@ async def async_setup_component(hass: core.HomeAssistant, domain: str, if setup_tasks is None: setup_tasks = hass.data[DATA_SETUP] = {} - task = setup_tasks[domain] = hass.async_add_job( + task = setup_tasks[domain] = hass.async_create_task( _async_setup_component(hass, domain, config)) - return await task + return await task # type: ignore async def _async_process_dependencies(hass, config, name, dependencies): @@ -142,7 +142,7 @@ async def _async_setup_component(hass: core.HomeAssistant, result = await component.async_setup( # type: ignore hass, processed_config) else: - result = await hass.async_add_job( + result = await hass.async_add_executor_job( component.setup, hass, processed_config) # type: ignore except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during setup of component %s", domain) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index d2138f4293c..a26f7014444 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -267,8 +267,8 @@ def color_xy_brightness_to_RGB(vX: float, vY: float, def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: """Convert a hsb into its rgb representation.""" if fS == 0: - fV = fB * 255 - return (fV, fV, fV) + fV = int(fB * 255) + return fV, fV, fV r = g = b = 0 h = fH / 60 diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 37b917baa2e..0f07a90e9bb 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -6,9 +6,11 @@ import re from typing import Any, Dict, Union, Optional, Tuple # NOQA import pytz +import pytz.exceptions as pytzexceptions DATE_STR_FORMAT = "%Y-%m-%d" -UTC = DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo +UTC = pytz.utc +DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo # Copyright (c) Django Software Foundation and individual contributors. @@ -42,7 +44,7 @@ def get_time_zone(time_zone_str: str) -> Optional[dt.tzinfo]: """ try: return pytz.timezone(time_zone_str) - except pytz.exceptions.UnknownTimeZoneError: + except pytzexceptions.UnknownTimeZoneError: return None @@ -64,7 +66,7 @@ def as_utc(dattim: dt.datetime) -> dt.datetime: if dattim.tzinfo == UTC: return dattim elif dattim.tzinfo is None: - dattim = DEFAULT_TIME_ZONE.localize(dattim) + dattim = DEFAULT_TIME_ZONE.localize(dattim) # type: ignore return dattim.astimezone(UTC) @@ -92,7 +94,7 @@ def as_local(dattim: dt.datetime) -> dt.datetime: def utc_from_timestamp(timestamp: float) -> dt.datetime: """Return a UTC time from a timestamp.""" - return dt.datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) + return UTC.localize(dt.datetime.utcfromtimestamp(timestamp)) def start_of_local_day(dt_or_d: @@ -102,13 +104,14 @@ def start_of_local_day(dt_or_d: date = now().date() # type: dt.date elif isinstance(dt_or_d, dt.datetime): date = dt_or_d.date() - return DEFAULT_TIME_ZONE.localize(dt.datetime.combine(date, dt.time())) + return DEFAULT_TIME_ZONE.localize(dt.datetime.combine( # type: ignore + date, dt.time())) # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. # https://github.com/django/django/blob/master/LICENSE -def parse_datetime(dt_str: str) -> dt.datetime: +def parse_datetime(dt_str: str) -> Optional[dt.datetime]: """Parse a string and return a datetime.datetime. This function supports time zone offsets. When the input contains one, @@ -134,14 +137,12 @@ def parse_datetime(dt_str: str) -> dt.datetime: if tzinfo_str[0] == '-': offset = -offset tzinfo = dt.timezone(offset) - else: - tzinfo = None kws = {k: int(v) for k, v in kws.items() if v is not None} kws['tzinfo'] = tzinfo return dt.datetime(**kws) -def parse_date(dt_str: str) -> dt.date: +def parse_date(dt_str: str) -> Optional[dt.date]: """Convert a date string to a date object.""" try: return dt.datetime.strptime(dt_str, DATE_STR_FORMAT).date() @@ -180,9 +181,8 @@ def get_age(date: dt.datetime) -> str: def formatn(number: int, unit: str) -> str: """Add "unit" if it's plural.""" if number == 1: - return "1 %s" % unit - elif number > 1: - return "%d %ss" % (number, unit) + return '1 {}'.format(unit) + return '{:d} {}s'.format(number, unit) def q_n_r(first: int, second: int) -> Tuple[int, int]: """Return quotient and remaining.""" @@ -210,4 +210,4 @@ def get_age(date: dt.datetime) -> str: if minute > 0: return formatn(minute, 'minute') - return formatn(second, 'second') if second > 0 else "0 seconds" + return formatn(second, 'second') diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 0e53342b0ca..1029e58c118 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -8,8 +8,6 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) -_UNDEFINED = object() - class SerializationError(HomeAssistantError): """Error serializing the data to JSON.""" @@ -19,7 +17,7 @@ class WriteError(HomeAssistantError): """Error writing the data.""" -def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ +def load_json(filename: str, default: Union[List, Dict, None] = None) \ -> Union[List, Dict]: """Load JSON data from a file and return as dict or list. @@ -27,7 +25,7 @@ def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ """ try: with open(filename, encoding='utf-8') as fdesc: - return json.loads(fdesc.read()) + return json.loads(fdesc.read()) # type: ignore except FileNotFoundError: # This is not a fatal error _LOGGER.debug('JSON file not found: %s', filename) @@ -37,7 +35,7 @@ def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ except OSError as error: _LOGGER.exception('JSON file reading failed: %s', filename) raise HomeAssistantError(error) - return {} if default is _UNDEFINED else default + return {} if default is None else default def save_json(filename: str, data: Union[List, Dict]): @@ -46,9 +44,9 @@ def save_json(filename: str, data: Union[List, Dict]): Returns True on success. """ try: - data = json.dumps(data, sort_keys=True, indent=4) + json_data = json.dumps(data, sort_keys=True, indent=4) with open(filename, 'w', encoding='utf-8') as fdesc: - fdesc.write(data) + fdesc.write(json_data) except TypeError as error: _LOGGER.exception('Failed to serialize to JSON: %s', filename) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index fc02009b7af..4f528cfcb51 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -6,21 +6,14 @@ import certifi def client_context(): """Return an SSL context for making requests.""" - context = _get_context() - context.verify_mode = ssl.CERT_REQUIRED - context.check_hostname = True - context.load_verify_locations(cafile=certifi.where(), capath=None) + context = ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, + cafile=certifi.where() + ) return context def server_context(): - """Return an SSL context for being a server.""" - context = _get_context() - context.options |= ssl.OP_CIPHER_SERVER_PREFERENCE - return context - - -def _get_context(): """Return an SSL context following the Mozilla recommendations. TLS configuration follows the best-practice guidelines specified here: @@ -31,7 +24,8 @@ def _get_context(): context.options |= ( ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | - ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | + ssl.OP_CIPHER_SERVER_PREFERENCE ) if hasattr(ssl, 'OP_NO_COMPRESSION'): context.options |= ssl.OP_NO_COMPRESSION diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index ecef1087747..4cc0fff96b9 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -86,11 +86,11 @@ class UnitSystem(object): self.volume_unit = volume @property - def is_metric(self: object) -> bool: + def is_metric(self) -> bool: """Determine if this is the metric unit system.""" return self.name == CONF_UNIT_SYSTEM_METRIC - def temperature(self: object, temperature: float, from_unit: str) -> float: + def temperature(self, temperature: float, from_unit: str) -> float: """Convert the given temperature to this unit system.""" if not isinstance(temperature, Number): raise TypeError( @@ -99,7 +99,7 @@ class UnitSystem(object): return temperature_util.convert(temperature, from_unit, self.temperature_unit) - def length(self: object, length: float, from_unit: str) -> float: + def length(self, length: float, from_unit: str) -> float: """Convert the given length to this unit system.""" if not isinstance(length, Number): raise TypeError('{} is not a numeric value.'.format(str(length))) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 0e7befd5e9e..298d52722a5 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -57,7 +57,7 @@ class SafeLineLoader(yaml.SafeLoader): last_line = self.line # type: int node = super(SafeLineLoader, self).compose_node(parent, index) # type: yaml.nodes.Node - node.__line__ = last_line + 1 + node.__line__ = last_line + 1 # type: ignore return node @@ -69,7 +69,7 @@ def load_yaml(fname: str) -> Union[List, Dict]: # We convert that to an empty dict return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict() except yaml.YAMLError as exc: - _LOGGER.error(exc) + _LOGGER.error(str(exc)) raise HomeAssistantError(exc) except UnicodeDecodeError as exc: _LOGGER.error("Unable to read file %s: %s", fname, exc) @@ -232,6 +232,8 @@ def _load_secret_yaml(secret_path: str) -> Dict: _LOGGER.debug('Loading %s', secret_path) try: secrets = load_yaml(secret_path) + if not isinstance(secrets, dict): + raise HomeAssistantError('Secrets is not a dictionary') if 'logger' in secrets: logger = str(secrets['logger']).lower() if logger == 'debug': diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000000..3970ea72d47 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,11 @@ +[mypy] +warn_redundant_casts = true +warn_unused_configs = true +ignore_missing_imports = true +follow_imports = silent +warn_unused_ignores = true +warn_return_any = true + +[mypy-homeassistant.util.yaml] +warn_return_any = false + diff --git a/requirements_all.txt b/requirements_all.txt index 4d39fc27b59..72dc74b0f66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ certifi>=2018.04.16 jinja2>=2.10 pip>=8.0.3 pytz>=2018.04 -pyyaml>=3.11,<4 +pyyaml>=3.13,<4 requests==2.19.1 voluptuous==0.11.1 @@ -66,7 +66,7 @@ TravisPy==0.3.5 TwitterAPI==2.5.4 # homeassistant.components.sensor.waze_travel_time -WazeRouteCalculator==0.5 +WazeRouteCalculator==0.6 # homeassistant.components.notify.yessssms YesssSMS==0.1.1b3 @@ -260,7 +260,7 @@ defusedxml==0.5.0 deluge-client==1.4.0 # homeassistant.components.media_player.denonavr -denonavr==0.7.3 +denonavr==0.7.4 # homeassistant.components.media_player.directv directpy==0.5 @@ -338,7 +338,7 @@ fints==0.2.1 fitbit==0.3.0 # homeassistant.components.sensor.fixer -fixerio==0.1.1 +fixerio==1.0.0a0 # homeassistant.components.light.flux_led flux_led==0.21 @@ -391,7 +391,7 @@ gstreamer-player==1.1.0 ha-ffmpeg==1.9 # homeassistant.components.media_player.philips_js -ha-philipsjs==0.0.4 +ha-philipsjs==0.0.5 # homeassistant.components.sensor.geo_rss_events haversine==0.4.5 @@ -415,26 +415,18 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180708.0 +home-assistant-frontend==20180720.0 # homeassistant.components.homekit_controller -# homekit==0.6 +# homekit==0.10 # homeassistant.components.homematicip_cloud -homematicip==0.9.4 - -# homeassistant.components.camera.onvif -http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a +homematicip==0.9.8 +# homeassistant.components.google # homeassistant.components.remember_the_milk httplib2==0.10.3 -# homeassistant.components.sensor.gtfs -https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 - -# homeassistant.components.media_player.lg_netcast -https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 - # homeassistant.components.hydrawise hydrawiser==0.1.1 @@ -476,7 +468,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==13.0.0 +keyring==13.2.1 # homeassistant.scripts.keyring keyrings.alt==3.1 @@ -513,7 +505,7 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.1.0 +limitlessled==1.1.2 # homeassistant.components.linode linode-api==4.1.9b1 @@ -636,7 +628,7 @@ pdunehd==1.3 # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora -pexpect==4.0.1 +pexpect==4.6.0 # homeassistant.components.rpi_pfio pifacecommon==4.1.2 @@ -736,7 +728,7 @@ pyairvisual==2.0.1 pyalarmdotcom==0.3.2 # homeassistant.components.arlo -pyarlo==0.1.8 +pyarlo==0.1.9 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.5 @@ -763,6 +755,9 @@ pyblackbird==0.5 # homeassistant.components.neato pybotvac==0.0.7 +# homeassistant.components.cloudflare +pycfdns==0.0.1 + # homeassistant.components.media_player.channels pychannels==1.0.0 @@ -786,7 +781,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==39 +pydeconz==42 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -794,6 +789,9 @@ pydispatcher==2.0.5 # homeassistant.components.android_ip_webcam pydroid-ipcam==0.8 +# homeassistant.components.sensor.duke_energy +pydukeenergy==0.0.6 + # homeassistant.components.sensor.ebox pyebox==1.1.4 @@ -836,6 +834,9 @@ pygatt==3.2.0 # homeassistant.components.cover.gogogate2 pygogogate2==0.1.1 +# homeassistant.components.sensor.gtfs +pygtfs-homeassistant==0.1.3.dev0 + # homeassistant.components.remote.harmony pyharmony==1.0.20 @@ -846,7 +847,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.44 +pyhomematic==0.1.45 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 @@ -881,6 +882,9 @@ pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm pylast==2.3.0 +# homeassistant.components.media_player.lg_netcast +pylgnetcast-homeassistant==0.2.0.dev0 + # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv pylgtv==0.1.7 @@ -922,7 +926,7 @@ pymusiccast==0.1.6 pymyq==0.0.11 # homeassistant.components.mysensors -pymysensors==0.14.0 +pymysensors==0.16.0 # homeassistant.components.lock.nello pynello==1.5.1 @@ -1080,7 +1084,7 @@ python-sochain-api==0.0.2 python-songpal==0.0.7 # homeassistant.components.sensor.synologydsm -python-synology==0.1.0 +python-synology==0.2.0 # homeassistant.components.tado python-tado==0.2.3 @@ -1113,7 +1117,7 @@ pythonegardia==1.0.39 pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile -pytile==1.1.0 +pytile==2.0.2 # homeassistant.components.climate.touchline pytouchline==0.7 @@ -1283,7 +1287,7 @@ spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.8 +sqlalchemy==1.2.9 # homeassistant.components.statsd statsd==3.2.1 @@ -1291,6 +1295,9 @@ statsd==3.2.1 # homeassistant.components.sensor.steam_online steamodd==4.21 +# homeassistant.components.camera.onvif +suds-passworddigest-homeassistant==0.1.2a0.dev0 + # homeassistant.components.camera.onvif suds-py3==1.3.3.0 @@ -1337,6 +1344,9 @@ total_connect_client==0.18 # homeassistant.components.switch.transmission transmissionrpc==0.11 +# homeassistant.components.tuya +tuyapy==0.1.2 + # homeassistant.components.twilio twilio==5.7.0 @@ -1432,7 +1442,7 @@ yeelight==0.4.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.06.25 +youtube_dl==2018.07.04 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_test.txt b/requirements_test.txt index 7ee0e166cf2..db53699379c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,12 +6,12 @@ coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 -mypy==0.590 +mypy==0.610 pydocstyle==1.1.1 pylint==1.9.2 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.0 -pytest==3.6.1 +pytest==3.6.3 requests_mock==1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ce3a97e811..3de2285eae9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,14 +7,14 @@ coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 -mypy==0.590 +mypy==0.610 pydocstyle==1.1.1 pylint==1.9.2 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.0 -pytest==3.6.1 +pytest==3.6.3 requests_mock==1.5 @@ -81,7 +81,10 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180708.0 +home-assistant-frontend==20180720.0 + +# homeassistant.components.homematicip_cloud +homematicip==0.9.8 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -110,7 +113,7 @@ paho-mqtt==1.3.1 # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora -pexpect==4.0.1 +pexpect==4.6.0 # homeassistant.components.pilight pilight==0.1.1 @@ -133,7 +136,7 @@ py-canary==0.5.0 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==39 +pydeconz==42 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -194,7 +197,7 @@ somecomfort==0.5.2 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.8 +sqlalchemy==1.2.9 # homeassistant.components.statsd statsd==3.2.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7bf87c74de7..9a5b4dd1a43 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -56,6 +56,7 @@ TEST_REQUIREMENTS = ( 'hbmqtt', 'holidays', 'home-assistant-frontend', + 'homematicip', 'influxdb', 'libpurecoollink', 'libsoundtouch', diff --git a/setup.py b/setup.py index 928d894c9d1..bbf10dd309d 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ REQUIRES = [ 'jinja2>=2.10', 'pip>=8.0.3', 'pytz>=2018.04', - 'pyyaml>=3.11,<4', + 'pyyaml>=3.13,<4', 'requests==2.19.1', 'voluptuous==0.11.1', ] diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 00000000000..48a99324b30 --- /dev/null +++ b/tests/auth/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant auth module.""" diff --git a/tests/auth_providers/__init__.py b/tests/auth/providers/__init__.py similarity index 100% rename from tests/auth_providers/__init__.py rename to tests/auth/providers/__init__.py diff --git a/tests/auth_providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py similarity index 66% rename from tests/auth_providers/test_homeassistant.py rename to tests/auth/providers/test_homeassistant.py index 1d9a29bf48b..9db6293d98a 100644 --- a/tests/auth_providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,8 +1,12 @@ """Test the Home Assistant local auth provider.""" +from unittest.mock import Mock + import pytest from homeassistant import data_entry_flow -from homeassistant.auth_providers import homeassistant as hass_auth +from homeassistant.auth import auth_manager_from_config +from homeassistant.auth.providers import ( + auth_provider_from_config, homeassistant as hass_auth) @pytest.fixture @@ -15,15 +19,15 @@ def data(hass): async def test_adding_user(data, hass): """Test adding a user.""" - data.add_user('test-user', 'test-pass') + data.add_auth('test-user', 'test-pass') data.validate_login('test-user', 'test-pass') async def test_adding_user_duplicate_username(data, hass): """Test adding a user.""" - data.add_user('test-user', 'test-pass') + data.add_auth('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidUser): - data.add_user('test-user', 'other-pass') + data.add_auth('test-user', 'other-pass') async def test_validating_password_invalid_user(data, hass): @@ -34,7 +38,7 @@ async def test_validating_password_invalid_user(data, hass): async def test_validating_password_invalid_password(data, hass): """Test validating an invalid user.""" - data.add_user('test-user', 'test-pass') + data.add_auth('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidAuth): data.validate_login('test-user', 'invalid-pass') @@ -43,7 +47,7 @@ async def test_validating_password_invalid_password(data, hass): async def test_changing_password(data, hass): """Test adding a user.""" user = 'test-user' - data.add_user(user, 'test-pass') + data.add_auth(user, 'test-pass') data.change_password(user, 'new-pass') with pytest.raises(hass_auth.InvalidAuth): @@ -60,7 +64,7 @@ async def test_changing_password_raises_invalid_user(data, hass): async def test_login_flow_validates(data, hass): """Test login flow.""" - data.add_user('test-user', 'test-pass') + data.add_auth('test-user', 'test-pass') await data.async_save() provider = hass_auth.HassAuthProvider(hass, None, {}) @@ -91,11 +95,38 @@ async def test_login_flow_validates(data, hass): async def test_saving_loading(data, hass): """Test saving and loading JSON.""" - data.add_user('test-user', 'test-pass') - data.add_user('second-user', 'second-pass') + data.add_auth('test-user', 'test-pass') + data.add_auth('second-user', 'second-pass') await data.async_save() data = hass_auth.Data(hass) await data.async_load() data.validate_login('test-user', 'test-pass') data.validate_login('second-user', 'second-pass') + + +async def test_not_allow_set_id(): + """Test we are not allowed to set an ID in config.""" + hass = Mock() + provider = await auth_provider_from_config(hass, None, { + 'type': 'homeassistant', + 'id': 'invalid', + }) + assert provider is None + + +async def test_new_users_populate_values(hass, data): + """Test that we populate data for new users.""" + data.add_auth('hello', 'test-pass') + await data.async_save() + + manager = await auth_manager_from_config(hass, [{ + 'type': 'homeassistant' + }]) + provider = manager.auth_providers[0] + credentials = await provider.async_get_or_create_credentials({ + 'username': 'hello' + }) + user = await manager.async_get_or_create_user(credentials) + assert user.name == 'hello' + assert user.is_active diff --git a/tests/auth_providers/test_insecure_example.py b/tests/auth/providers/test_insecure_example.py similarity index 75% rename from tests/auth_providers/test_insecure_example.py rename to tests/auth/providers/test_insecure_example.py index 3377a60c45b..b472e4c95df 100644 --- a/tests/auth_providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -4,8 +4,8 @@ import uuid import pytest -from homeassistant import auth -from homeassistant.auth_providers import insecure_example +from homeassistant.auth import auth_store, models as auth_models, AuthManager +from homeassistant.auth.providers import insecure_example from tests.common import mock_coro @@ -13,7 +13,7 @@ from tests.common import mock_coro @pytest.fixture def store(hass): """Mock store.""" - return auth.AuthStore(hass) + return auth_store.AuthStore(hass) @pytest.fixture @@ -23,6 +23,7 @@ def provider(hass, store): 'type': 'insecure_example', 'users': [ { + 'name': 'Test Name', 'username': 'user-test', 'password': 'password-test', }, @@ -34,7 +35,15 @@ def provider(hass, store): }) -async def test_create_new_credential(provider): +@pytest.fixture +def manager(hass, store, provider): + """Mock manager.""" + return AuthManager(hass, store, { + (provider.type, provider.id): provider + }) + + +async def test_create_new_credential(manager, provider): """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials({ 'username': 'user-test', @@ -42,10 +51,14 @@ async def test_create_new_credential(provider): }) assert credentials.is_new is True + user = await manager.async_get_or_create_user(credentials) + assert user.name == 'Test Name' + assert user.is_active + async def test_match_existing_credentials(store, provider): """See if we match existing users.""" - existing = auth.Credentials( + existing = auth_models.Credentials( id=uuid.uuid4(), auth_provider_type='insecure_example', auth_provider_id=None, @@ -54,7 +67,7 @@ async def test_match_existing_credentials(store, provider): }, is_new=False, ) - store.credentials_for_provider = Mock(return_value=mock_coro([existing])) + provider.async_credentials = Mock(return_value=mock_coro([existing])) credentials = await provider.async_get_or_create_credentials({ 'username': 'user-test', 'password': 'password-test', diff --git a/tests/auth_providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py similarity index 72% rename from tests/auth_providers/test_legacy_api_password.py rename to tests/auth/providers/test_legacy_api_password.py index 7a8f17894aa..71642bd7a32 100644 --- a/tests/auth_providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -4,13 +4,14 @@ from unittest.mock import Mock import pytest from homeassistant import auth -from homeassistant.auth_providers import legacy_api_password +from homeassistant.auth import auth_store +from homeassistant.auth.providers import legacy_api_password @pytest.fixture def store(hass): """Mock store.""" - return auth.AuthStore(hass) + return auth_store.AuthStore(hass) @pytest.fixture @@ -21,20 +22,32 @@ def provider(hass, store): }) -async def test_create_new_credential(provider): +@pytest.fixture +def manager(hass, store, provider): + """Mock manager.""" + return auth.AuthManager(hass, store, { + (provider.type, provider.id): provider + }) + + +async def test_create_new_credential(manager, provider): """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials({}) assert credentials.data["username"] is legacy_api_password.LEGACY_USER assert credentials.is_new is True + user = await manager.async_get_or_create_user(credentials) + assert user.name == legacy_api_password.LEGACY_USER + assert user.is_active -async def test_only_one_credentials(store, provider): + +async def test_only_one_credentials(manager, provider): """Call create twice will return same credential.""" credentials = await provider.async_get_or_create_credentials({}) - await store.async_get_or_create_user(credentials, provider) + await manager.async_get_or_create_user(credentials) credentials2 = await provider.async_get_or_create_credentials({}) - assert credentials2.data["username"] is legacy_api_password.LEGACY_USER - assert credentials2.id is credentials.id + assert credentials2.data["username"] == legacy_api_password.LEGACY_USER + assert credentials2.id == credentials.id assert credentials2.is_new is False diff --git a/tests/test_auth.py b/tests/auth/test_init.py similarity index 71% rename from tests/test_auth.py rename to tests/auth/test_init.py index 5b545223c15..cad4bbdbd71 100644 --- a/tests/test_auth.py +++ b/tests/auth/test_init.py @@ -5,8 +5,11 @@ from unittest.mock import Mock, patch import pytest from homeassistant import auth, data_entry_flow +from homeassistant.auth import ( + models as auth_models, auth_store, const as auth_const) from homeassistant.util import dt as dt_util -from tests.common import MockUser, ensure_auth_manager_loaded, flush_store +from tests.common import ( + MockUser, ensure_auth_manager_loaded, flush_store, CLIENT_ID) @pytest.fixture @@ -43,7 +46,7 @@ async def test_auth_manager_from_config_validates_config_and_id(mock_hass): 'name': provider.name, 'id': provider.id, 'type': provider.type, - } for provider in manager.async_auth_providers] + } for provider in manager.auth_providers] assert providers == [{ 'name': 'Test Name', 'type': 'insecure_example', @@ -77,7 +80,7 @@ async def test_create_new_user(hass, hass_storage): credentials = step['result'] user = await manager.async_get_or_create_user(credentials) assert user is not None - assert user.is_owner is True + assert user.is_owner is False assert user.name == 'Test Name' @@ -93,6 +96,21 @@ async def test_login_as_existing_user(mock_hass): }]) ensure_auth_manager_loaded(manager) + # Add a fake user that we're not going to log in with + user = MockUser( + id='mock-user2', + is_owner=False, + is_active=False, + name='Not user', + ).add_to_auth_manager(manager) + user.credentials.append(auth_models.Credentials( + id='mock-id2', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'other-user'}, + is_new=False, + )) + # Add fake user with credentials for example auth provider. user = MockUser( id='mock-user', @@ -100,7 +118,7 @@ async def test_login_as_existing_user(mock_hass): is_active=False, name='Paulus', ).add_to_auth_manager(manager) - user.credentials.append(auth.Credentials( + user.credentials.append(auth_models.Credentials( id='mock-id', auth_provider_type='insecure_example', auth_provider_id=None, @@ -180,103 +198,107 @@ async def test_saving_loading(hass, hass_storage): 'password': 'test-pass', }) user = await manager.async_get_or_create_user(step['result']) - - client = await manager.async_create_client( - 'test', redirect_uris=['https://example.com']) - - refresh_token = await manager.async_create_refresh_token(user, client.id) + 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 flush_store(manager._store._store) - store2 = auth.AuthStore(hass) + store2 = auth_store.AuthStore(hass) users = await store2.async_get_users() assert len(users) == 1 assert users[0] == user - clients = await store2.async_get_clients() - assert len(clients) == 1 - assert clients[0] == client - def test_access_token_expired(): """Test that the expired property on access tokens work.""" - refresh_token = auth.RefreshToken( + refresh_token = auth_models.RefreshToken( user=None, client_id='bla' ) - access_token = auth.AccessToken( + access_token = auth_models.AccessToken( refresh_token=refresh_token ) assert access_token.expired is False - with patch('homeassistant.auth.dt_util.utcnow', - return_value=dt_util.utcnow() + auth.ACCESS_TOKEN_EXPIRATION): + 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.ACCESS_TOKEN_EXPIRATION - timedelta(1) - with patch('homeassistant.auth.dt_util.utcnow', return_value=almost_exp): + 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, []) - client = await manager.async_create_client('test') - user = MockUser( - id='mock-user', - is_owner=False, - is_active=False, - name='Paulus', - ).add_to_auth_manager(manager) - refresh_token = await manager.async_create_refresh_token(user, client.id) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) assert refresh_token.user.id is user.id - assert refresh_token.client_id is client.id + 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 - with patch('homeassistant.auth.dt_util.utcnow', - return_value=dt_util.utcnow() + auth.ACCESS_TOKEN_EXPIRATION): + 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 # Even with unpatched time, it should have been removed from manager assert manager.async_get_access_token(access_token.token) is None -async def test_get_or_create_client(hass): - """Test that get_or_create_client works.""" +async def test_generating_system_user(hass): + """Test that we can add a system user.""" manager = await auth.auth_manager_from_config(hass, []) - - client1 = await manager.async_get_or_create_client( - 'Test Client', redirect_uris=['https://test.com/1']) - assert client1.name is 'Test Client' - - client2 = await manager.async_get_or_create_client( - 'Test Client', redirect_uris=['https://test.com/1']) - assert client2.id is client1.id + user = await manager.async_create_system_user('Hass.io') + token = await manager.async_create_refresh_token(user) + assert user.system_generated + assert token is not None + assert token.client_id is None -async def test_cannot_create_refresh_token_with_invalide_client_id(hass): - """Test that we cannot create refresh token with invalid client id.""" +async def test_refresh_token_requires_client_for_user(hass): + """Test that we can add a system user.""" manager = await auth.auth_manager_from_config(hass, []) - user = MockUser( - id='mock-user', - is_owner=False, - is_active=False, - name='Paulus', + user = MockUser().add_to_auth_manager(manager) + assert user.system_generated is False + + with pytest.raises(ValueError): + await manager.async_create_refresh_token(user) + + token = await manager.async_create_refresh_token(user, CLIENT_ID) + assert token is not None + assert token.client_id == CLIENT_ID + + +async def test_refresh_token_not_requires_client_for_system_user(hass): + """Test that we can add a system user.""" + manager = await auth.auth_manager_from_config(hass, []) + user = await manager.async_create_system_user('Hass.io') + assert user.system_generated is True + + with pytest.raises(ValueError): + await manager.async_create_refresh_token(user, CLIENT_ID) + + token = await manager.async_create_refresh_token(user) + assert token is not None + assert token.client_id is None + + +async def test_cannot_deactive_owner(mock_hass): + """Test that we cannot deactive the owner.""" + manager = await auth.auth_manager_from_config(mock_hass, []) + owner = MockUser( + is_owner=True, ).add_to_auth_manager(manager) - with pytest.raises(ValueError): - await manager.async_create_refresh_token(user, 'bla') - -async def test_cannot_create_refresh_token_with_invalide_user(hass): - """Test that we cannot create refresh token with invalid client id.""" - manager = await auth.auth_manager_from_config(hass, []) - client = await manager.async_create_client('test') - user = MockUser(id='invalid-user') with pytest.raises(ValueError): - await manager.async_create_refresh_token(user, client.id) + await manager.async_deactivate_user(owner) diff --git a/tests/common.py b/tests/common.py index 3a51cd3e059..b03d473e6f3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,5 +1,6 @@ """Test the helper method for writing tests.""" import asyncio +from collections import OrderedDict from datetime import timedelta import functools as ft import json @@ -12,6 +13,8 @@ import threading from contextlib import contextmanager from homeassistant import auth, core as ha, data_entry_flow, 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 from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -31,6 +34,8 @@ from homeassistant.util.async_ import ( _TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) INSTANCES = [] +CLIENT_ID = 'https://example.com/app' +CLIENT_REDIRECT_URI = 'https://example.com/app/callback' def threadsafe_callback_factory(func): @@ -112,7 +117,7 @@ def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) hass.config.async_load = Mock() - store = auth.AuthStore(hass) + store = auth_store.AuthStore(hass) hass.auth = auth.AuthManager(hass, store, {}) ensure_auth_manager_loaded(hass.auth) INSTANCES.append(hass) @@ -306,13 +311,15 @@ def mock_registry(hass, mock_entries=None): return registry -class MockUser(auth.User): +class MockUser(auth_models.User): """Mock a user in Home Assistant.""" - def __init__(self, id='mock-id', is_owner=True, is_active=True, - name='Mock User'): + def __init__(self, id='mock-id', is_owner=False, is_active=True, + name='Mock User', system_generated=False): """Initialize mock user.""" - super().__init__(id, is_owner, is_active, name) + super().__init__( + id=id, is_owner=is_owner, is_active=is_active, name=name, + system_generated=system_generated) def add_to_hass(self, hass): """Test helper to add entry to hass.""" @@ -325,14 +332,27 @@ class MockUser(auth.User): return self +async def register_auth_provider(hass, config): + """Helper to register an auth provider.""" + provider = await auth_providers.auth_provider_from_config( + hass, hass.auth._store, config) + assert provider is not None, 'Invalid config specified' + key = (provider.type, provider.id) + providers = hass.auth._providers + + if key in providers: + raise ValueError('Provider already registered') + + providers[key] = provider + return provider + + @ha.callback def ensure_auth_manager_loaded(auth_mgr): """Ensure an auth manager is considered loaded.""" store = auth_mgr._store - if store._clients is None: - store._clients = {} if store._users is None: - store._users = {} + store._users = OrderedDict() class MockModule(object): @@ -729,7 +749,13 @@ def mock_storage(data=None): if store.key not in data: return None - store._data = data.get(store.key) + mock_data = data.get(store.key) + + if 'data' not in mock_data or 'version' not in mock_data: + _LOGGER.error('Mock data needs "version" and "data"') + raise ValueError('Mock data needs "version" and "data"') + + store._data = mock_data # Route through original load so that we trigger migration loaded = await orig_load(store) diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index 21719c12569..ce94d1ecbfa 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -1,6 +1,4 @@ """Tests for the auth component.""" -from aiohttp.helpers import BasicAuth - from homeassistant import auth from homeassistant.setup import async_setup_component @@ -16,10 +14,6 @@ BASE_CONFIG = [{ 'name': 'Test Name' }] }] -CLIENT_ID = 'test-id' -CLIENT_SECRET = 'test-secret' -CLIENT_AUTH = BasicAuth(CLIENT_ID, CLIENT_SECRET) -CLIENT_REDIRECT_URI = 'http://example.com/callback' async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, @@ -32,9 +26,6 @@ async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, 'api_password': 'bla' } }) - client = auth.Client('Test Client', CLIENT_ID, CLIENT_SECRET, - redirect_uris=[CLIENT_REDIRECT_URI]) - hass.auth._store._clients[client.id] = client if setup_api: await async_setup_component(hass, 'api', {}) return await aiohttp_client(hass.http.app) diff --git a/tests/components/auth/test_client.py b/tests/components/auth/test_client.py deleted file mode 100644 index 65ad22efae2..00000000000 --- a/tests/components/auth/test_client.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for the client validator.""" -from aiohttp.helpers import BasicAuth -import pytest - -from homeassistant.setup import async_setup_component -from homeassistant.components.auth.client import verify_client -from homeassistant.components.http.view import HomeAssistantView - -from . import async_setup_auth - - -@pytest.fixture -def mock_view(hass): - """Register a view that verifies client id/secret.""" - hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) - - clients = [] - - class ClientView(HomeAssistantView): - url = '/' - name = 'bla' - - @verify_client - async def get(self, request, client): - """Handle GET request.""" - clients.append(client) - - hass.http.register_view(ClientView) - return clients - - -async def test_verify_client(hass, aiohttp_client, mock_view): - """Test that verify client can extract client auth from a request.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth(client.id, client.secret)) - assert resp.status == 200 - assert mock_view[0] is client - - -async def test_verify_client_no_auth_header(hass, aiohttp_client, mock_view): - """Test that verify client will decline unknown client id.""" - http_client = await async_setup_auth(hass, aiohttp_client) - - resp = await http_client.get('/') - assert resp.status == 401 - assert mock_view == [] - - -async def test_verify_client_invalid_client_id(hass, aiohttp_client, - mock_view): - """Test that verify client will decline unknown client id.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth('invalid', client.secret)) - assert resp.status == 401 - assert mock_view == [] - - -async def test_verify_client_invalid_client_secret(hass, aiohttp_client, - mock_view): - """Test that verify client will decline incorrect client secret.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth(client.id, 'invalid')) - assert resp.status == 401 - assert mock_view == [] diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py new file mode 100644 index 00000000000..7bd720ddf70 --- /dev/null +++ b/tests/components/auth/test_indieauth.py @@ -0,0 +1,110 @@ +"""Tests for the client validator.""" +from homeassistant.components.auth import indieauth + +import pytest + + +def test_client_id_scheme(): + """Test we enforce valid scheme.""" + assert indieauth._parse_client_id('http://ex.com/') + assert indieauth._parse_client_id('https://ex.com/') + + with pytest.raises(ValueError): + indieauth._parse_client_id('ftp://ex.com') + + +def test_client_id_path(): + """Test we enforce valid path.""" + assert indieauth._parse_client_id('http://ex.com').path == '/' + assert indieauth._parse_client_id('http://ex.com/hello').path == '/hello' + assert indieauth._parse_client_id( + 'http://ex.com/hello/.world').path == '/hello/.world' + assert indieauth._parse_client_id( + 'http://ex.com/hello./.world').path == '/hello./.world' + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/.') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/hello/./yo') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/hello/../yo') + + +def test_client_id_fragment(): + """Test we enforce valid fragment.""" + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/#yoo') + + +def test_client_id_user_pass(): + """Test we enforce valid username/password.""" + with pytest.raises(ValueError): + indieauth._parse_client_id('http://user@ex.com/') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://user:pass@ex.com/') + + +def test_client_id_hostname(): + """Test we enforce valid hostname.""" + assert indieauth._parse_client_id('http://www.home-assistant.io/') + assert indieauth._parse_client_id('http://[::1]') + assert indieauth._parse_client_id('http://127.0.0.1') + assert indieauth._parse_client_id('http://10.0.0.0') + assert indieauth._parse_client_id('http://10.255.255.255') + assert indieauth._parse_client_id('http://172.16.0.0') + assert indieauth._parse_client_id('http://172.31.255.255') + assert indieauth._parse_client_id('http://192.168.0.0') + assert indieauth._parse_client_id('http://192.168.255.255') + + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://255.255.255.255/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://11.0.0.0/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://172.32.0.0/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://192.167.0.0/') + + +def test_parse_url_lowercase_host(): + """Test we update empty paths.""" + assert indieauth._parse_url('http://ex.com/hello').path == '/hello' + assert indieauth._parse_url('http://EX.COM/hello').hostname == 'ex.com' + + parts = indieauth._parse_url('http://EX.COM:123/HELLO') + assert parts.netloc == 'ex.com:123' + assert parts.path == '/HELLO' + + +def test_parse_url_path(): + """Test we update empty paths.""" + assert indieauth._parse_url('http://ex.com').path == '/' + + +def test_verify_redirect_uri(): + """Test that we verify redirect uri correctly.""" + assert indieauth.verify_redirect_uri( + 'http://ex.com', + 'http://ex.com/callback' + ) + + # Different domain + assert not indieauth.verify_redirect_uri( + 'http://ex.com', + 'http://different.com/callback' + ) + + # Different scheme + assert not indieauth.verify_redirect_uri( + 'http://ex.com', + 'https://ex.com/callback' + ) + + # Different subdomain + assert not indieauth.verify_redirect_uri( + 'https://sub1.ex.com', + 'https://sub2.ex.com/callback' + ) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 7cff04327b8..807bf15854b 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -1,22 +1,33 @@ """Integration tests for the auth component.""" -from . import async_setup_auth, CLIENT_AUTH, CLIENT_REDIRECT_URI +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow +from homeassistant.components import auth + +from . import async_setup_auth + +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI -async def test_login_new_user_and_refresh_token(hass, aiohttp_client): +async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): """Test logging in with new user and refreshing tokens.""" client = await async_setup_auth(hass, aiohttp_client, setup_api=True) resp = await client.post('/auth/login_flow', json={ + 'client_id': CLIENT_ID, 'handler': ['insecure_example', None], 'redirect_uri': CLIENT_REDIRECT_URI, - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'test-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -24,9 +35,10 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): # Exchange code for tokens resp = await client.post('/auth/token', data={ - 'grant_type': 'authorization_code', - 'code': code - }, auth=CLIENT_AUTH) + 'client_id': CLIENT_ID, + 'grant_type': 'authorization_code', + 'code': code + }) assert resp.status == 200 tokens = await resp.json() @@ -35,9 +47,10 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): # Use refresh token to get more tokens. resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, 'grant_type': 'refresh_token', 'refresh_token': tokens['refresh_token'] - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 tokens = await resp.json() @@ -52,3 +65,68 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): 'authorization': 'Bearer {}'.format(tokens['access_token']) }) assert resp.status == 200 + + +def test_credential_store_expiration(): + """Test that the credential store will not return expired tokens.""" + store, retrieve = auth._create_cred_store() + client_id = 'bla' + credentials = 'creds' + now = utcnow() + + with patch('homeassistant.util.dt.utcnow', return_value=now): + code = store(client_id, credentials) + + with patch('homeassistant.util.dt.utcnow', + return_value=now + timedelta(minutes=10)): + assert retrieve(client_id, code) is None + + with patch('homeassistant.util.dt.utcnow', return_value=now): + code = store(client_id, credentials) + + with patch('homeassistant.util.dt.utcnow', + return_value=now + timedelta(minutes=9, seconds=59)): + assert retrieve(client_id, code) == credentials + + +async def test_ws_current_user(hass, hass_ws_client, hass_access_token): + """Test the current user command.""" + assert await async_setup_component(hass, 'auth', { + 'http': { + 'api_password': 'bla' + } + }) + with patch('homeassistant.auth.AuthManager.active', return_value=True): + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth.WS_TYPE_CURRENT_USER, + }) + + result = await client.receive_json() + assert result['success'], result + + user = hass_access_token.refresh_token.user + user_dict = result['result'] + + assert user_dict['name'] == user.name + assert user_dict['id'] == user.id + assert user_dict['is_owner'] == user.is_owner + + +async def test_cors_on_token(hass, aiohttp_client): + """Test logging in with new user and refreshing tokens.""" + client = await async_setup_auth(hass, aiohttp_client) + + resp = await client.options('/auth/token', headers={ + 'origin': 'http://example.com', + 'Access-Control-Request-Method': 'POST', + }) + assert resp.headers['Access-Control-Allow-Origin'] == 'http://example.com' + assert resp.headers['Access-Control-Allow-Methods'] == 'POST' + + resp = await client.post('/auth/token', headers={ + 'origin': 'http://example.com' + }) + assert resp.headers['Access-Control-Allow-Origin'] == 'http://example.com' diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py index 853c002ba46..13515db87fa 100644 --- a/tests/components/auth/test_init_link_user.py +++ b/tests/components/auth/test_init_link_user.py @@ -1,5 +1,7 @@ """Tests for the link user flow.""" -from . import async_setup_auth, CLIENT_AUTH, CLIENT_ID, CLIENT_REDIRECT_URI +from . import async_setup_auth + +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI async def async_get_code(hass, aiohttp_client): @@ -23,51 +25,25 @@ async def async_get_code(hass, aiohttp_client): }] }] client = await async_setup_auth(hass, aiohttp_client, config) - - resp = await client.post('/auth/login_flow', json={ - 'handler': ['insecure_example', None], - 'redirect_uri': CLIENT_REDIRECT_URI, - }, auth=CLIENT_AUTH) - assert resp.status == 200 - step = await resp.json() - - resp = await client.post( - '/auth/login_flow/{}'.format(step['flow_id']), json={ - 'username': 'test-user', - 'password': 'test-pass', - }, auth=CLIENT_AUTH) - - assert resp.status == 200 - step = await resp.json() - code = step['result'] - - # Exchange code for tokens - resp = await client.post('/auth/token', data={ - 'grant_type': 'authorization_code', - 'code': code - }, auth=CLIENT_AUTH) - - assert resp.status == 200 - tokens = await resp.json() - - access_token = hass.auth.async_get_access_token(tokens['access_token']) - assert access_token is not None - user = access_token.refresh_token.user - assert len(user.credentials) == 1 + user = await hass.auth.async_create_user(name='Hello') + refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID) + access_token = hass.auth.async_create_access_token(refresh_token) # Now authenticate with the 2nd flow resp = await client.post('/auth/login_flow', json={ + 'client_id': CLIENT_ID, 'handler': ['insecure_example', '2nd auth'], 'redirect_uri': CLIENT_REDIRECT_URI, - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': '2nd-user', 'password': '2nd-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -76,7 +52,7 @@ async def async_get_code(hass, aiohttp_client): 'user': user, 'code': step['result'], 'client': client, - 'tokens': tokens, + 'access_token': access_token.token, } @@ -85,18 +61,17 @@ async def test_link_user(hass, aiohttp_client): info = await async_get_code(hass, aiohttp_client) client = info['client'] code = info['code'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': CLIENT_ID, 'code': code }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 200 - assert len(info['user'].credentials) == 2 + assert len(info['user'].credentials) == 1 async def test_link_user_invalid_client_id(hass, aiohttp_client): @@ -104,36 +79,34 @@ async def test_link_user_invalid_client_id(hass, aiohttp_client): info = await async_get_code(hass, aiohttp_client) client = info['client'] code = info['code'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': 'invalid', 'code': code }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 400 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 async def test_link_user_invalid_code(hass, aiohttp_client): """Test linking a user to new credentials.""" info = await async_get_code(hass, aiohttp_client) client = info['client'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': CLIENT_ID, 'code': 'invalid' }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 400 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 async def test_link_user_invalid_auth(hass, aiohttp_client): @@ -149,4 +122,4 @@ async def test_link_user_invalid_auth(hass, aiohttp_client): }, headers={'authorization': 'Bearer invalid'}) assert resp.status == 401 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 diff --git a/tests/components/auth/test_init_login_flow.py b/tests/components/auth/test_init_login_flow.py index ad39fba3997..50bd03d6ced 100644 --- a/tests/components/auth/test_init_login_flow.py +++ b/tests/components/auth/test_init_login_flow.py @@ -1,13 +1,13 @@ """Tests for the login flow.""" -from aiohttp.helpers import BasicAuth +from . import async_setup_auth -from . import async_setup_auth, CLIENT_AUTH, CLIENT_REDIRECT_URI +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI async def test_fetch_auth_providers(hass, aiohttp_client): """Test fetching auth providers.""" client = await async_setup_auth(hass, aiohttp_client) - resp = await client.get('/auth/providers', auth=CLIENT_AUTH) + resp = await client.get('/auth/providers') assert await resp.json() == [{ 'name': 'Example', 'type': 'insecure_example', @@ -15,14 +15,6 @@ async def test_fetch_auth_providers(hass, aiohttp_client): }] -async def test_fetch_auth_providers_require_valid_client(hass, aiohttp_client): - """Test fetching auth providers.""" - client = await async_setup_auth(hass, aiohttp_client) - resp = await client.get('/auth/providers', - auth=BasicAuth('invalid', 'bla')) - assert resp.status == 401 - - async def test_cannot_get_flows_in_progress(hass, aiohttp_client): """Test we cannot get flows in progress.""" client = await async_setup_auth(hass, aiohttp_client, []) @@ -34,18 +26,20 @@ async def test_invalid_username_password(hass, aiohttp_client): """Test we cannot get flows in progress.""" client = await async_setup_auth(hass, aiohttp_client) resp = await client.post('/auth/login_flow', json={ + 'client_id': CLIENT_ID, 'handler': ['insecure_example', None], 'redirect_uri': CLIENT_REDIRECT_URI - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() # Incorrect username resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'wrong-user', 'password': 'test-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -56,9 +50,10 @@ async def test_invalid_username_password(hass, aiohttp_client): # Incorrect password resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'wrong-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() diff --git a/tests/components/binary_sensor/test_trend.py b/tests/components/binary_sensor/test_trend.py index c1083cc1857..b77f9060b40 100644 --- a/tests/components/binary_sensor/test_trend.py +++ b/tests/components/binary_sensor/test_trend.py @@ -273,8 +273,7 @@ class TestTrendBinarySensor: state = self.hass.states.get('binary_sensor.test_trend_sensor') assert state.state == 'off' - def test_invalid_name_does_not_create(self): \ - # pylint: disable=invalid-name + def test_invalid_name_does_not_create(self): """Test invalid name.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { @@ -290,8 +289,7 @@ class TestTrendBinarySensor: }) assert self.hass.states.all() == [] - def test_invalid_sensor_does_not_create(self): \ - # pylint: disable=invalid-name + def test_invalid_sensor_does_not_create(self): """Test invalid sensor.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { diff --git a/tests/components/camera/test_push.py b/tests/components/camera/test_push.py new file mode 100644 index 00000000000..78053e540f5 --- /dev/null +++ b/tests/components/camera/test_push.py @@ -0,0 +1,63 @@ +"""The tests for generic camera component.""" +import io + +from datetime import timedelta + +from homeassistant import core as ha +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from tests.components.auth import async_setup_auth + + +async def test_bad_posting(aioclient_mock, hass, aiohttp_client): + """Test that posting to wrong api endpoint fails.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'push', + 'name': 'config_test', + }}) + + client = await async_setup_auth(hass, aiohttp_client) + + # missing file + resp = await client.post('/api/camera_push/camera.config_test') + assert resp.status == 400 + + files = {'image': io.BytesIO(b'fake')} + + # wrong entity + resp = await client.post('/api/camera_push/camera.wrong', data=files) + assert resp.status == 400 + + +async def test_posting_url(aioclient_mock, hass, aiohttp_client): + """Test that posting to api endpoint works.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'push', + 'name': 'config_test', + }}) + + client = await async_setup_auth(hass, aiohttp_client) + files = {'image': io.BytesIO(b'fake')} + + # initial state + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' + + # post image + resp = await client.post('/api/camera_push/camera.config_test', data=files) + assert resp.status == 200 + + # state recording + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'recording' + + # await timeout + shifted_time = dt_util.utcnow() + timedelta(seconds=15) + hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: shifted_time}) + await hass.async_block_till_done() + + # back to initial state + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py new file mode 100644 index 00000000000..fe8f351955f --- /dev/null +++ b/tests/components/config/test_auth.py @@ -0,0 +1,211 @@ +"""Test config entries API.""" +from unittest.mock import PropertyMock, patch + +import pytest + +from homeassistant.auth import models as auth_models +from homeassistant.components.config import auth as auth_config + +from tests.common import MockUser, CLIENT_ID + + +@pytest.fixture(autouse=True) +def auth_active(hass): + """Mock that auth is active.""" + with patch('homeassistant.auth.AuthManager.active', + PropertyMock(return_value=True)): + yield + + +@pytest.fixture(autouse=True) +def setup_config(hass, aiohttp_client): + """Fixture that sets up the auth provider homeassistant module.""" + hass.loop.run_until_complete(auth_config.async_setup(hass)) + + +async def test_list_requires_owner(hass, hass_ws_client, hass_access_token): + """Test get users requires auth.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_LIST, + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_list(hass, hass_ws_client): + """Test get users.""" + owner = MockUser( + id='abc', + name='Test Owner', + is_owner=True, + ).add_to_hass(hass) + + owner.credentials.append(auth_models.Credentials( + auth_provider_type='homeassistant', + auth_provider_id=None, + data={}, + )) + + system = MockUser( + id='efg', + name='Test Hass.io', + system_generated=True + ).add_to_hass(hass) + + inactive = MockUser( + id='hij', + name='Inactive User', + is_active=False, + ).add_to_hass(hass) + + refresh_token = await hass.auth.async_create_refresh_token( + owner, CLIENT_ID) + access_token = hass.auth.async_create_access_token(refresh_token) + + client = await hass_ws_client(hass, access_token) + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_LIST, + }) + + result = await client.receive_json() + assert result['success'], result + data = result['result'] + assert len(data) == 3 + assert data[0] == { + 'id': owner.id, + 'name': 'Test Owner', + 'is_owner': True, + 'is_active': True, + 'system_generated': False, + 'credentials': [{'type': 'homeassistant'}] + } + assert data[1] == { + 'id': system.id, + 'name': 'Test Hass.io', + 'is_owner': False, + 'is_active': True, + 'system_generated': True, + 'credentials': [], + } + assert data[2] == { + 'id': inactive.id, + 'name': 'Inactive User', + 'is_owner': False, + 'is_active': False, + 'system_generated': False, + 'credentials': [], + } + + +async def test_delete_requires_owner(hass, hass_ws_client, hass_access_token): + """Test delete command requires an owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': 'abcd', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +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) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': hass_access_token.refresh_token.user.id, + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +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 + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': 'abcd', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'not_found' + + +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 + test_user = MockUser( + id='efg', + ).add_to_hass(hass) + + assert len(await hass.auth.async_get_users()) == 2 + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': test_user.id, + }) + + result = await client.receive_json() + assert result['success'], result + assert len(await hass.auth.async_get_users()) == 1 + + +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 + + assert len(await hass.auth.async_get_users()) == 1 + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_CREATE, + 'name': 'Paulus', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(await hass.auth.async_get_users()) == 2 + data_user = result['result']['user'] + user = await hass.auth.async_get_user(data_user['id']) + assert user is not None + assert user.name == data_user['name'] + assert user.is_active + assert not user.is_owner + assert not user.system_generated + + +async def test_create_requires_owner(hass, hass_ws_client, hass_access_token): + """Test create command requires an owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_CREATE, + 'name': 'YO', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py new file mode 100644 index 00000000000..cd2cbc44539 --- /dev/null +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -0,0 +1,303 @@ +"""Test config entries API.""" +import pytest + +from homeassistant.auth.providers import homeassistant as prov_ha +from homeassistant.components.config import ( + auth_provider_homeassistant as auth_ha) + +from tests.common import MockUser, register_auth_provider + + +@pytest.fixture(autouse=True) +def setup_config(hass, aiohttp_client): + """Fixture that sets up the auth provider homeassistant module.""" + hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant' + })) + hass.loop.run_until_complete(auth_ha.async_setup(hass)) + + +async def test_create_auth_system_generated_user(hass, hass_access_token, + hass_ws_client): + """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 + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': system_user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + + assert not result['success'], result + assert result['error']['code'] == 'system_generated' + + +async def test_create_auth_user_already_credentials(): + """Test we can't create auth for user with pre-existing credentials.""" + # assert False + + +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 + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': 'test-id', + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + + assert not result['success'], result + assert result['error']['code'] == 'not_found' + + +async def test_create_auth_requires_owner(hass, hass_ws_client, + hass_access_token): + """Test create requires owner to call API.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': 'test-id', + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_create_auth(hass, hass_ws_client, hass_access_token, + hass_storage): + """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 + + assert len(user.credentials) == 0 + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(user.credentials) == 1 + creds = user.credentials[0] + assert creds.auth_provider_type == 'homeassistant' + assert creds.auth_provider_id is None + assert creds.data == { + 'username': 'test-user' + } + assert prov_ha.STORAGE_KEY in hass_storage + entry = hass_storage[prov_ha.STORAGE_KEY]['data']['users'][0] + assert entry['username'] == 'test-user' + + +async def test_create_auth_duplicate_username(hass, hass_ws_client, + hass_access_token, hass_storage): + """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 + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'username_exists' + + +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 + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(hass_storage[prov_ha.STORAGE_KEY]['data']['users']) == 0 + + +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 + + user = MockUser().add_to_hass(hass) + user.credentials.append( + await hass.auth.auth_providers[0].async_get_or_create_credentials({ + 'username': 'test-user'})) + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(hass_storage[prov_ha.STORAGE_KEY]['data']['users']) == 0 + + +async def test_delete_requires_owner(hass, hass_ws_client, hass_access_token): + """Test delete requires owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +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 + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'auth_not_found' + + +async def test_change_password(hass, hass_ws_client, hass_access_token): + """Test that change password succeeds with valid password.""" + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await hass.async_add_executor_job( + provider.data.add_auth, 'test-user', 'test-pass') + + credentials = await provider.async_get_or_create_credentials({ + 'username': 'test-user' + }) + + user = hass_access_token.refresh_token.user + await hass.auth.async_link_user(user, credentials) + + client = await hass_ws_client(hass, hass_access_token) + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'test-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert result['success'], result + await provider.async_validate_login('test-user', 'new-pass') + + +async def test_change_password_wrong_pw(hass, hass_ws_client, + hass_access_token): + """Test that change password fails with invalid password.""" + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await hass.async_add_executor_job( + provider.data.add_auth, 'test-user', 'test-pass') + + credentials = await provider.async_get_or_create_credentials({ + 'username': 'test-user' + }) + + user = hass_access_token.refresh_token.user + await hass.auth.async_link_user(user, credentials) + + client = await hass_ws_client(hass, hass_access_token) + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'wrong-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'invalid_password' + with pytest.raises(prov_ha.InvalidAuth): + await provider.async_validate_login('test-user', 'new-pass') + + +async def test_change_password_no_creds(hass, hass_ws_client, + hass_access_token): + """Test that change password fails with no credentials.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'test-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'credentials_not_found' diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 8a1b934ab76..5f6a17a4101 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -2,20 +2,35 @@ import pytest from homeassistant.setup import async_setup_component +from homeassistant.components import websocket_api -from tests.common import MockUser +from tests.common import MockUser, CLIENT_ID @pytest.fixture def hass_ws_client(aiohttp_client): """Websocket client fixture connected to websocket server.""" - async def create_client(hass): + async def create_client(hass, access_token=None): """Create a websocket client.""" wapi = hass.components.websocket_api assert await async_setup_component(hass, 'websocket_api') client = await aiohttp_client(hass.http.app) websocket = await client.ws_connect(wapi.URL) + auth_resp = await websocket.receive_json() + + if auth_resp['type'] == wapi.TYPE_AUTH_OK: + assert access_token is None, \ + 'Access token given but no auth required' + return websocket + + assert access_token is not None, 'Access token required for fixture' + + await websocket.send_json({ + 'type': websocket_api.TYPE_AUTH, + 'access_token': access_token.token + }) + auth_ok = await websocket.receive_json() assert auth_ok['type'] == wapi.TYPE_AUTH_OK @@ -28,11 +43,6 @@ def hass_ws_client(aiohttp_client): def hass_access_token(hass): """Return an access token to access Home Assistant.""" user = MockUser().add_to_hass(hass) - client = hass.loop.run_until_complete(hass.auth.async_create_client( - 'Access Token Fixture', - redirect_uris=['/'], - no_secret=True, - )) refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(user, client.id)) + hass.auth.async_create_refresh_token(user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py old mode 100755 new mode 100644 diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 0cbece6d1b0..956b407eeaa 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -168,8 +168,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): scanner.last_results = WAKE_DEVICES self.assertEqual(list(WAKE_DEVICES), scanner.scan_devices()) - def test_password_or_pub_key_required(self): \ - # pylint: disable=invalid-name + def test_password_or_pub_key_required(self): """Test creating an AsusWRT scanner without a pass or pubkey.""" with assert_setup_component(0, DOMAIN): assert setup_component( @@ -183,8 +182,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): @mock.patch( 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', return_value=mock.MagicMock()) - def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): \ - # pylint: disable=invalid-name + def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): """Test creating an AsusWRT scanner with a password and no pubkey.""" conf_dict = { DOMAIN: { @@ -213,8 +211,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): @mock.patch( 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', return_value=mock.MagicMock()) - def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): \ - # pylint: disable=invalid-name + def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): """Test creating an AsusWRT scanner with a pubkey and no password.""" conf_dict = { device_tracker.DOMAIN: { @@ -292,8 +289,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): password='fake_pass', port=22) ) - def test_ssh_login_without_password_or_pubkey(self): \ - # pylint: disable=invalid-name + def test_ssh_login_without_password_or_pubkey(self): """Test that login is not called without password or pub_key.""" ssh = mock.MagicMock() ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) @@ -363,8 +359,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): mock.call(b'#') ) - def test_telnet_login_without_password(self): \ - # pylint: disable=invalid-name + def test_telnet_login_without_password(self): """Test that login is not called without password or pub_key.""" telnet = mock.MagicMock() telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index 78750e91f83..de7865517a8 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -31,8 +31,7 @@ class TestComponentsDeviceTrackerMQTT(unittest.TestCase): except FileNotFoundError: pass - def test_ensure_device_tracker_platform_validation(self): \ - # pylint: disable=invalid-name + def test_ensure_device_tracker_platform_validation(self): """Test if platform validation was done.""" @asyncio.coroutine def mock_setup_scanner(hass, config, see, discovery_info=None): diff --git a/tests/components/device_tracker/test_mqtt_json.py b/tests/components/device_tracker/test_mqtt_json.py index 43f4fc3bbf3..8ab6346f19b 100644 --- a/tests/components/device_tracker/test_mqtt_json.py +++ b/tests/components/device_tracker/test_mqtt_json.py @@ -41,8 +41,7 @@ class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): except FileNotFoundError: pass - def test_ensure_device_tracker_platform_validation(self): \ - # pylint: disable=invalid-name + def test_ensure_device_tracker_platform_validation(self): """Test if platform validation was done.""" @asyncio.coroutine def mock_setup_scanner(hass, config, see, discovery_info=None): diff --git a/tests/components/device_tracker/test_unifi_direct.py b/tests/components/device_tracker/test_unifi_direct.py index ccfa59404a1..d1ede721142 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -45,8 +45,7 @@ class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): @mock.patch(scanner_path, return_value=mock.MagicMock()) - def test_get_scanner(self, unifi_mock): \ - # pylint: disable=invalid-name + def test_get_scanner(self, unifi_mock): """Test creating an Unifi direct scanner with a password.""" conf_dict = { DOMAIN: { diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 1617f327d27..c99d273a458 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -130,10 +130,10 @@ def hue_client(loop, hass_hue, aiohttp_client): } }) - HueUsernameView().register(web_app.router) - HueAllLightsStateView(config).register(web_app.router) - HueOneLightStateView(config).register(web_app.router) - HueOneLightChangeView(config).register(web_app.router) + HueUsernameView().register(web_app, web_app.router) + HueAllLightsStateView(config).register(web_app, web_app.router) + HueOneLightStateView(config).register(web_app, web_app.router) + HueOneLightChangeView(config).register(web_app, web_app.router) return loop.run_until_complete(aiohttp_client(web_app)) diff --git a/tests/components/homematicip_cloud/__init__.py b/tests/components/homematicip_cloud/__init__.py new file mode 100644 index 00000000000..1d89bd73183 --- /dev/null +++ b/tests/components/homematicip_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the HomematicIP Cloud component.""" diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py new file mode 100644 index 00000000000..1c2e54a1a5d --- /dev/null +++ b/tests/components/homematicip_cloud/test_config_flow.py @@ -0,0 +1,150 @@ +"""Tests for HomematicIP Cloud config flow.""" +from unittest.mock import patch + +from homeassistant.components.homematicip_cloud import hap as hmipc +from homeassistant.components.homematicip_cloud import config_flow, const + +from tests.common import MockConfigEntry, mock_coro + + +async def test_flow_works(hass): + """Test config flow works.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', return_value=mock_coro()), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_register', + return_value=mock_coro(True)): + hap.authtoken = 'ABC' + result = await flow.async_step_init(user_input=config) + + assert hap.authtoken == 'ABC' + assert result['type'] == 'create_entry' + + +async def test_flow_init_connection_error(hass): + """Test config flow with accesspoint connection error.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'form' + + +async def test_flow_link_connection_error(hass): + """Test config flow client registration connection error.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_register', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'abort' + + +async def test_flow_link_press_button(hass): + """Test config flow ask for pressing the blue button.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'form' + assert result['errors'] == {'base': 'press_the_button'} + + +async def test_init_flow_show_form(hass): + """Test config flow shows up with a form.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input=None) + assert result['type'] == 'form' + + +async def test_init_already_configured(hass): + """Test accesspoint is already configured.""" + MockConfigEntry(domain=const.DOMAIN, data={ + const.HMIPC_HAPID: 'ABC123', + }).add_to_hass(hass) + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'abort' + + +async def test_import_config(hass): + """Test importing a host with an existing config file.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'ABC123' + assert result['data'] == { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + } + + +async def test_import_existing_config(hass): + """Test abort of an existing accesspoint from config.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + MockConfigEntry(domain=const.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + }).add_to_hass(hass) + + result = await flow.async_step_import({ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + }) + + assert result['type'] == 'abort' diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py new file mode 100644 index 00000000000..476bed368d7 --- /dev/null +++ b/tests/components/homematicip_cloud/test_hap.py @@ -0,0 +1,115 @@ +"""Test HomematicIP Cloud accesspoint.""" +from unittest.mock import Mock, patch + +from homeassistant.components.homematicip_cloud import hap as hmipc +from homeassistant.components.homematicip_cloud import const, errors +from tests.common import mock_coro + + +async def test_auth_setup(hass): + """Test auth setup for client registration.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', return_value=mock_coro()): + assert await hap.async_setup() is True + + +async def test_auth_setup_connection_error(hass): + """Test auth setup connection error behaviour.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', + side_effect=errors.HmipcConnectionError): + assert await hap.async_setup() is False + + +async def test_auth_auth_check_and_register(hass): + """Test auth client registration.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + hap.auth = Mock() + with patch.object(hap.auth, 'isRequestAcknowledged', + return_value=mock_coro()), \ + patch.object(hap.auth, 'requestAuthToken', + return_value=mock_coro('ABC')), \ + patch.object(hap.auth, 'confirmAuthToken', + return_value=mock_coro()): + assert await hap.async_checkbutton() is True + assert await hap.async_register() == 'ABC' + + +async def test_hap_setup_works(aioclient_mock): + """Test a successful setup of a accesspoint.""" + hass = Mock() + entry = Mock() + home = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', return_value=mock_coro(home)): + assert await hap.async_setup() is True + + assert hap.home is home + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'alarm_control_panel') + assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ + (entry, 'binary_sensor') + + +async def test_hap_setup_connection_error(): + """Test a failed accesspoint setup.""" + hass = Mock() + entry = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', + side_effect=errors.HmipcConnectionError): + assert await hap.async_setup() is False + + assert len(hass.async_add_job.mock_calls) == 0 + assert len(hass.config_entries.flow.async_init.mock_calls) == 0 + + +async def test_hap_reset_unloads_entry_if_setup(): + """Test calling reset while the entry has been setup.""" + hass = Mock() + entry = Mock() + home = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', return_value=mock_coro(home)): + assert await hap.async_setup() is True + + assert hap.home is home + assert len(hass.services.async_register.mock_calls) == 0 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + await hap.async_reset() + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 6 diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py new file mode 100644 index 00000000000..18537227247 --- /dev/null +++ b/tests/components/homematicip_cloud/test_init.py @@ -0,0 +1,103 @@ +"""Test HomematicIP Cloud setup process.""" + +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import homematicip_cloud as hmipc + +from tests.common import mock_coro, MockConfigEntry + + +async def test_config_with_accesspoint_passed_to_config_entry(hass): + """Test that config for a accesspoint are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=[]): + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'name', + } + }) is True + + # Flow started for the access point + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_already_registered_not_passed_to_config_entry(hass): + """Test that an already registered accesspoint does not get imported.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=['ABC123']): + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'name', + } + }) is True + + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_setup_entry_successful(hass): + """Test setup entry is successful.""" + entry = MockConfigEntry(domain=hmipc.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + }) + entry.add_to_hass(hass) + with patch.object(hmipc, 'HomematicipHAP') as mock_hap: + mock_hap.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'hmip', + } + }) is True + + assert len(mock_hap.mock_calls) == 2 + + +async def test_setup_defined_accesspoint(hass): + """Test we initiate config entry for the accesspoint.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=[]): + mock_config_entries.flow.async_init.return_value = mock_coro() + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'hmip', + } + }) is True + + assert len(mock_config_entries.flow.mock_calls) == 1 + assert mock_config_entries.flow.mock_calls[0][2]['data'] == { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry(domain=hmipc.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + }) + entry.add_to_hass(hass) + + with patch.object(hmipc, 'HomematicipHAP') as mock_hap: + mock_hap.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hmipc.DOMAIN, {}) is True + + assert len(mock_hap.return_value.mock_calls) == 1 + + mock_hap.return_value.async_reset.return_value = mock_coro(True) + assert await hmipc.async_unload_entry(hass, entry) + assert len(mock_hap.return_value.async_reset.mock_calls) == 1 + assert hass.data[hmipc.DOMAIN] == {} diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 3e5eed4c924..31cba79a6c8 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,13 +1,12 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access from ipaddress import ip_network -from unittest.mock import patch, Mock +from unittest.mock import patch import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -from homeassistant.auth import AccessToken, RefreshToken from homeassistant.components.http.auth import setup_auth from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip @@ -16,8 +15,6 @@ from homeassistant.setup import async_setup_component from . import mock_real_ip -ACCESS_TOKEN = 'tk.1234' - API_PASSWORD = 'test1234' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases @@ -39,33 +36,21 @@ async def mock_handler(request): return web.Response(status=200) -def mock_async_get_access_token(token): - """Return if token is valid.""" - if token == ACCESS_TOKEN: - return Mock(spec=AccessToken, - token=ACCESS_TOKEN, - refresh_token=Mock(spec=RefreshToken)) - else: - return None - - @pytest.fixture -def app(): +def app(hass): """Fixture to setup a web.Application.""" app = web.Application() - mock_auth = Mock(async_get_access_token=mock_async_get_access_token) - app['hass'] = Mock(auth=mock_auth) + app['hass'] = hass app.router.add_get('/', mock_handler) setup_real_ip(app, False, []) return app @pytest.fixture -def app2(): +def app2(hass): """Fixture to setup a web.Application without real_ip middleware.""" app = web.Application() - mock_auth = Mock(async_get_access_token=mock_async_get_access_token) - app['hass'] = Mock(auth=mock_auth) + app['hass'] = hass app.router.add_get('/', mock_handler) return app @@ -171,33 +156,35 @@ async def test_access_with_trusted_ip(app2, aiohttp_client): async def test_auth_active_access_with_access_token_in_header( - app, aiohttp_client): + app, aiohttp_client, hass_access_token): """Test access with access token in header.""" + token = hass_access_token.token setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) req = await client.get( - '/', headers={'Authorization': 'Bearer {}'.format(ACCESS_TOKEN)}) + '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 200 req = await client.get( - '/', headers={'AUTHORIZATION': 'Bearer {}'.format(ACCESS_TOKEN)}) + '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)}) assert req.status == 200 req = await client.get( - '/', headers={'authorization': 'Bearer {}'.format(ACCESS_TOKEN)}) + '/', headers={'authorization': 'Bearer {}'.format(token)}) assert req.status == 200 req = await client.get( - '/', headers={'Authorization': ACCESS_TOKEN}) + '/', headers={'Authorization': token}) assert req.status == 401 req = await client.get( - '/', headers={'Authorization': 'BEARER {}'.format(ACCESS_TOKEN)}) + '/', headers={'Authorization': 'BEARER {}'.format(token)}) assert req.status == 401 + hass_access_token.refresh_token.user.is_active = False req = await client.get( - '/', headers={'Authorization': 'Bearer wrong-pass'}) + '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 401 diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index c5691cf3e2a..a6a07928113 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -1,14 +1,18 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access -from unittest.mock import patch, mock_open +from ipaddress import ip_address +from unittest.mock import patch, mock_open, Mock from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized +from aiohttp.web_middlewares import middleware +from homeassistant.components.http import KEY_AUTHENTICATED +from homeassistant.components.http.view import request_handler_factory from homeassistant.setup import async_setup_component import homeassistant.components.http as http from homeassistant.components.http.ban import ( - IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS) + IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS, KEY_FAILED_LOGIN_ATTEMPTS) from . import mock_real_ip @@ -88,3 +92,53 @@ async def test_ip_bans_file_creation(hass, aiohttp_client): resp = await client.get('/') assert resp.status == 403 assert m.call_count == 1 + + +async def test_failed_login_attempts_counter(hass, aiohttp_client): + """Testing if failed login attempts counter increased.""" + app = web.Application() + app['hass'] = hass + + async def auth_handler(request): + """Return 200 status code.""" + return None, 200 + + app.router.add_get('/auth_true', request_handler_factory( + Mock(requires_auth=True), auth_handler)) + app.router.add_get('/auth_false', request_handler_factory( + Mock(requires_auth=True), auth_handler)) + app.router.add_get('/', request_handler_factory( + Mock(requires_auth=False), auth_handler)) + + setup_bans(hass, app, 5) + remote_ip = ip_address("200.201.202.204") + mock_real_ip(app)("200.201.202.204") + + @middleware + async def mock_auth(request, handler): + """Mock auth middleware.""" + if 'auth_true' in request.path: + request[KEY_AUTHENTICATED] = True + else: + request[KEY_AUTHENTICATED] = False + return await handler(request) + + app.middlewares.append(mock_auth) + + client = await aiohttp_client(app) + + resp = await client.get('/auth_false') + assert resp.status == 401 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 1 + + resp = await client.get('/auth_false') + assert resp.status == 401 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + resp = await client.get('/') + assert resp.status == 200 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + resp = await client.get('/auth_true') + assert resp.status == 200 + assert remote_ip not in app[KEY_FAILED_LOGIN_ATTEMPTS] diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 27367b4173e..523d4943ba0 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -19,14 +19,14 @@ from homeassistant.components.http.cors import setup_cors TRUSTED_ORIGIN = 'https://home-assistant.io' -async def test_cors_middleware_not_loaded_by_default(hass): +async def test_cors_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_cors') as mock_setup: await async_setup_component(hass, 'http', { 'http': {} }) - assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 1 async def test_cors_middleware_loaded_from_config(hass): diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index 2b966daff6c..b5eed19eb61 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -23,7 +23,7 @@ async def get_client(aiohttp_client, validator): """Test method.""" return b'' - TestView().register(app.router) + TestView().register(app, app.router) client = await aiohttp_client(app) return client diff --git a/tests/components/image_processing/test_facebox.py b/tests/components/image_processing/test_facebox.py index 9449ebf5f71..86811f94db3 100644 --- a/tests/components/image_processing/test_facebox.py +++ b/tests/components/image_processing/test_facebox.py @@ -1,5 +1,5 @@ """The tests for the facebox component.""" -from unittest.mock import patch +from unittest.mock import Mock, mock_open, patch import pytest import requests @@ -13,21 +13,26 @@ 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_FACE = {'confidence': 0.5812028911604818, 'id': 'john.jpg', 'matched': True, 'name': 'John Lennon', - 'rect': {'height': 75, 'left': 63, 'top': 262, 'width': 74} - } + 'rect': {'height': 75, 'left': 63, 'top': 262, 'width': 74}} + +MOCK_FILE_PATH = '/images/mock.jpg' MOCK_JSON = {"facesCount": 1, "success": True, - "faces": [MOCK_FACE] - } + "faces": [MOCK_FACE]} + +MOCK_NAME = 'mock_name' # Faces data after parsing. PARSED_FACES = [{ATTR_NAME: 'John Lennon', @@ -38,8 +43,7 @@ PARSED_FACES = [{ATTR_NAME: 'John Lennon', 'height': 75, 'left': 63, 'top': 262, - 'width': 74}, - }] + 'width': 74}}] MATCHED_FACES = {'John Lennon': 58.12} @@ -58,16 +62,42 @@ VALID_CONFIG = { } +@pytest.fixture +def mock_isfile(): + """Mock os.path.isfile.""" + with patch('homeassistant.components.image_processing.facebox.cv.isfile', + return_value=True) as _mock_isfile: + yield _mock_isfile + + +@pytest.fixture +def mock_open_file(): + """Mock open.""" + mopen = mock_open() + with patch('homeassistant.components.image_processing.facebox.open', + mopen, create=True) as _mock_open: + yield _mock_open + + def test_encode_image(): """Test that binary data is encoded correctly.""" assert fb.encode_image(b'test') == 'dGVzdA==' +def test_get_matched_faces(): + """Test that matched_faces are parsed correctly.""" + assert fb.get_matched_faces(PARSED_FACES) == MATCHED_FACES + + def test_parse_faces(): """Test parsing of raw face data, and generation of matched_faces.""" - parsed_faces = fb.parse_faces(MOCK_JSON['faces']) - assert parsed_faces == PARSED_FACES - assert fb.get_matched_faces(parsed_faces) == MATCHED_FACES + assert fb.parse_faces(MOCK_JSON['faces']) == PARSED_FACES + + +@patch('os.access', Mock(return_value=False)) +def test_valid_file_path(): + """Test that an invalid file_path is caught.""" + assert not fb.valid_file_path('test_path') @pytest.fixture @@ -110,6 +140,7 @@ async def test_process_image(hass, mock_image): state = hass.states.get(VALID_ENTITY_ID) assert state.state == '1' assert state.attributes.get('matched_faces') == MATCHED_FACES + assert state.attributes.get('total_matched_faces') == 1 PARSED_FACES[0][ATTR_ENTITY_ID] = VALID_ENTITY_ID # Update. assert state.attributes.get('faces') == PARSED_FACES @@ -134,7 +165,7 @@ async def test_connection_error(hass, mock_image): with requests_mock.Mocker() as mock_req: url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) mock_req.register_uri( - 'POST', url, exc=requests.exceptions.ConnectTimeout) + 'POST', url, exc=requests.exceptions.ConnectTimeout) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, @@ -147,15 +178,69 @@ async def test_connection_error(hass, mock_image): assert state.attributes.get('matched_faces') == {} +async def test_teach_service(hass, mock_image, mock_isfile, mock_open_file): + """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) + + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, status_code=200) + 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 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. + 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) + 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 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 + + async def test_setup_platform_with_name(hass): """Setup platform with one entity and a name.""" - MOCK_NAME = 'mock_name' - NAMED_ENTITY_ID = 'image_processing.{}'.format(MOCK_NAME) + named_entity_id = 'image_processing.{}'.format(MOCK_NAME) - VALID_CONFIG_NAMED = VALID_CONFIG.copy() - VALID_CONFIG_NAMED[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME + valid_config_named = VALID_CONFIG.copy() + valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG_NAMED) - assert hass.states.get(NAMED_ENTITY_ID) - state = hass.states.get(NAMED_ENTITY_ID) + await async_setup_component(hass, ip.DOMAIN, valid_config_named) + assert hass.states.get(named_entity_id) + state = hass.states.get(named_entity_id) assert state.attributes.get(CONF_FRIENDLY_NAME) == MOCK_NAME diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 49bcd8a73ec..7d6dd65e90a 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -177,8 +177,7 @@ class TestLightMQTT(unittest.TestCase): }) self.assertIsNone(self.hass.states.get('light.test')) - def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): \ - # pylint: disable=invalid-name + def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): """Test if there is no color and brightness if no topic.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -209,8 +208,7 @@ class TestLightMQTT(unittest.TestCase): self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) - def test_controlling_state_via_topic(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic(self): """Test the controlling of the state via topic.""" config = {light.DOMAIN: { 'platform': 'mqtt', @@ -410,8 +408,7 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(255, light_state.attributes['white_value']) - def test_controlling_state_via_topic_with_templates(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic_with_templates(self): """Test the setting og the state with a template.""" config = {light.DOMAIN: { 'platform': 'mqtt', @@ -466,8 +463,7 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(75, state.attributes.get('white_value')) self.assertEqual((0.14, 0.131), state.attributes.get('xy_color')) - def test_sending_mqtt_commands_and_optimistic(self): \ - # pylint: disable=invalid-name + def test_sending_mqtt_commands_and_optimistic(self): """Test the sending of command in optimistic mode.""" config = {light.DOMAIN: { 'platform': 'mqtt', diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index af560bff9c3..f16685b3575 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -115,8 +115,7 @@ class TestLightMQTTJSON(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() - def test_fail_setup_if_no_command_topic(self): \ - # pylint: disable=invalid-name + def test_fail_setup_if_no_command_topic(self): """Test if setup fails with no command topic.""" with assert_setup_component(0, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -127,8 +126,7 @@ class TestLightMQTTJSON(unittest.TestCase): }) self.assertIsNone(self.hass.states.get('light.test')) - def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ - # pylint: disable=invalid-name + def test_no_color_brightness_color_temp_white_val_if_no_topics(self): """Test for no RGB, brightness, color temp, effect, white val or XY.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -163,8 +161,7 @@ class TestLightMQTTJSON(unittest.TestCase): self.assertIsNone(state.attributes.get('xy_color')) self.assertIsNone(state.attributes.get('hs_color')) - def test_controlling_state_via_topic(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic(self): """Test the controlling of the state via topic.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -283,8 +280,7 @@ class TestLightMQTTJSON(unittest.TestCase): light_state = self.hass.states.get('light.test') self.assertEqual(155, light_state.attributes.get('white_value')) - def test_sending_mqtt_commands_and_optimistic(self): \ - # pylint: disable=invalid-name + def test_sending_mqtt_commands_and_optimistic(self): """Test the sending of command in optimistic mode.""" fake_state = ha.State('light.test', 'on', {'brightness': 95, 'hs_color': [100, 100], @@ -413,8 +409,7 @@ class TestLightMQTTJSON(unittest.TestCase): 's': 50.0, }, message_json["color"]) - def test_flash_short_and_long(self): \ - # pylint: disable=invalid-name + def test_flash_short_and_long(self): """Test for flash length being sent when included.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -547,8 +542,7 @@ class TestLightMQTTJSON(unittest.TestCase): self.assertEqual(STATE_ON, state.state) self.assertEqual(255, state.attributes.get('brightness')) - def test_invalid_color_brightness_and_white_values(self): \ - # pylint: disable=invalid-name + def test_invalid_color_brightness_and_white_values(self): """Test that invalid color/brightness/white values are ignored.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 1440a73f98e..e1c3da50e7e 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -51,8 +51,7 @@ class TestLightMQTTTemplate(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() - def test_setup_fails(self): \ - # pylint: disable=invalid-name + def test_setup_fails(self): """Test that setup fails with missing required configuration items.""" with assert_setup_component(0, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -63,8 +62,7 @@ class TestLightMQTTTemplate(unittest.TestCase): }) self.assertIsNone(self.hass.states.get('light.test')) - def test_state_change_via_topic(self): \ - # pylint: disable=invalid-name + def test_state_change_via_topic(self): """Test state change via topic.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -103,8 +101,7 @@ class TestLightMQTTTemplate(unittest.TestCase): self.assertIsNone(state.attributes.get('color_temp')) self.assertIsNone(state.attributes.get('white_value')) - def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ - # pylint: disable=invalid-name + def test_state_brightness_color_effect_temp_white_change_via_topic(self): """Test state, bri, color, effect, color temp, white val change.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -206,8 +203,7 @@ class TestLightMQTTTemplate(unittest.TestCase): light_state = self.hass.states.get('light.test') self.assertEqual('rainbow', light_state.attributes.get('effect')) - def test_optimistic(self): \ - # pylint: disable=invalid-name + def test_optimistic(self): """Test optimistic mode.""" fake_state = ha.State('light.test', 'on', {'brightness': 95, 'hs_color': [100, 100], @@ -289,8 +285,7 @@ class TestLightMQTTTemplate(unittest.TestCase): self.assertEqual(200, state.attributes['color_temp']) self.assertEqual(139, state.attributes['white_value']) - def test_flash(self): \ - # pylint: disable=invalid-name + def test_flash(self): """Test flash.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -353,8 +348,7 @@ class TestLightMQTTTemplate(unittest.TestCase): self.mock_publish.async_publish.assert_called_once_with( 'test_light_rgb/set', 'off,4', 0, False) - def test_invalid_values(self): \ - # pylint: disable=invalid-name + def test_invalid_values(self): """Test that invalid values are ignored.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { diff --git a/tests/components/light/test_zwave.py b/tests/components/light/test_zwave.py index 4966b161360..62bcf834b98 100644 --- a/tests/components/light/test_zwave.py +++ b/tests/components/light/test_zwave.py @@ -255,6 +255,32 @@ def test_set_white_value(mock_openzwave): assert color.data == '#ffffffc800' +def test_disable_white_if_set_color(mock_openzwave): + """ + Test that _white is set to 0 if turn_on with ATTR_HS_COLOR. + + See Issue #13930 - many RGBW ZWave bulbs will only activate the RGB LED to + produce color if _white is set to zero. + """ + node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR]) + value = MockValue(data=0, node=node) + color = MockValue(data='#0000000000', node=node) + # Supports RGB only + color_channels = MockValue(data=0x1c, node=node) + values = MockLightValues(primary=value, color=color, + color_channels=color_channels) + device = zwave.get_device(node=node, values=values, node_config={}) + device._white = 234 + + assert color.data == '#0000000000' + assert device.white_value == 234 + + device.turn_on(**{ATTR_HS_COLOR: (30, 50)}) + + assert device.white_value == 0 + assert color.data == '#ffbf7f0000' + + def test_zw098_set_color_temp(mock_openzwave): """Test setting zwave light color.""" node = MockNode(manufacturer_id='0086', product_id='0062', diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 9b4c0c69ac6..1c37c9049f3 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -1,5 +1,8 @@ """The tests for the MQTT component embedded server.""" from unittest.mock import Mock, MagicMock, patch +import sys + +import pytest from homeassistant.setup import setup_component import homeassistant.components.mqtt as mqtt @@ -7,6 +10,9 @@ import homeassistant.components.mqtt as mqtt from tests.common import get_test_home_assistant, mock_coro +# Until https://github.com/beerfactory/hbmqtt/pull/139 is released +@pytest.mark.skipif(sys.version_info[:2] >= (3, 7), + reason='Package incompatible with Python 3.7') class TestMQTT: """Test the MQTT component.""" diff --git a/tests/components/onboarding/__init__.py b/tests/components/onboarding/__init__.py new file mode 100644 index 00000000000..62c6dc929a1 --- /dev/null +++ b/tests/components/onboarding/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the onboarding component.""" + +from homeassistant.components import onboarding + + +def mock_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + 'version': onboarding.STORAGE_VERSION, + 'data': data + } diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py new file mode 100644 index 00000000000..57a81a78da3 --- /dev/null +++ b/tests/components/onboarding/test_init.py @@ -0,0 +1,77 @@ +"""Tests for the init.""" +from unittest.mock import patch, Mock + +from homeassistant.setup import async_setup_component +from homeassistant.components import onboarding + +from tests.common import mock_coro, MockUser + +from . import mock_storage + +# Temporarily: if auth not active, always set onboarded=True + + +async def test_not_setup_views_if_onboarded(hass, hass_storage): + """Test if onboarding is done, we don't setup views.""" + mock_storage(hass_storage, { + 'done': onboarding.STEPS + }) + + with patch( + 'homeassistant.components.onboarding.views.async_setup' + ) as mock_setup: + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 0 + assert onboarding.DOMAIN not in hass.data + assert onboarding.async_is_onboarded(hass) + + +async def test_setup_views_if_not_onboarded(hass): + """Test if onboarding is not done, we setup views.""" + with patch( + 'homeassistant.components.onboarding.views.async_setup', + return_value=mock_coro() + ) as mock_setup: + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 1 + assert onboarding.DOMAIN in hass.data + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + assert not onboarding.async_is_onboarded(hass) + + +async def test_is_onboarded(): + """Test the is onboarded function.""" + hass = Mock() + hass.data = {} + + with patch('homeassistant.auth.AuthManager.active', return_value=False): + assert onboarding.async_is_onboarded(hass) + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + assert onboarding.async_is_onboarded(hass) + + hass.data[onboarding.DOMAIN] = True + assert onboarding.async_is_onboarded(hass) + + hass.data[onboarding.DOMAIN] = False + assert not onboarding.async_is_onboarded(hass) + + +async def test_having_owner_finishes_user_step(hass, hass_storage): + """If owner user already exists, mark user step as complete.""" + MockUser(is_owner=True).add_to_hass(hass) + + with patch( + 'homeassistant.components.onboarding.views.async_setup' + ) as mock_setup, patch.object(onboarding, 'STEPS', [onboarding.STEP_USER]): + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 0 + assert onboarding.DOMAIN not in hass.data + assert onboarding.async_is_onboarded(hass) + + done = hass_storage[onboarding.STORAGE_KEY]['data']['done'] + assert onboarding.STEP_USER in done diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py new file mode 100644 index 00000000000..d6a4030190d --- /dev/null +++ b/tests/components/onboarding/test_views.py @@ -0,0 +1,137 @@ +"""Test the onboarding views.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import onboarding +from homeassistant.components.onboarding import views + +from tests.common import register_auth_provider + +from . import mock_storage + + +@pytest.fixture(autouse=True) +def auth_active(hass): + """Ensure auth is always active.""" + hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant' + })) + + +async def test_onboarding_progress(hass, hass_storage, aiohttp_client): + """Test fetching progress.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + client = await aiohttp_client(hass.http.app) + + with patch.object(views, 'STEPS', ['hello', 'world']): + resp = await client.get('/api/onboarding') + + assert resp.status == 200 + data = await resp.json() + assert len(data) == 2 + assert data[0] == { + 'step': 'hello', + 'done': True + } + assert data[1] == { + 'step': 'world', + 'done': False + } + + +async def test_onboarding_user_already_done(hass, hass_storage, + aiohttp_client): + """Test creating a new user when user step already done.""" + mock_storage(hass_storage, { + 'done': [views.STEP_USER] + }) + + with patch.object(onboarding, 'STEPS', ['hello', 'world']): + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'name': 'Test Name', + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 403 + + +async def test_onboarding_user(hass, hass_storage, aiohttp_client): + """Test creating a new user.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'name': 'Test Name', + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 200 + users = await hass.auth.async_get_users() + assert len(users) == 1 + user = users[0] + assert user.name == 'Test Name' + assert len(user.credentials) == 1 + assert user.credentials[0].data['username'] == 'test-user' + + +async def test_onboarding_user_invalid_name(hass, hass_storage, + aiohttp_client): + """Test not providing name.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 400 + + +async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): + """Test race condition on creating new user.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp1 = client.post('/api/onboarding/users', json={ + 'name': 'Test 1', + 'username': '1-user', + 'password': '1-pass', + }) + resp2 = client.post('/api/onboarding/users', json={ + 'name': 'Test 2', + 'username': '2-user', + 'password': '2-pass', + }) + + res1, res2 = await asyncio.gather(resp1, resp2) + + assert sorted([res1.status, res2.status]) == [200, 403] diff --git a/tests/components/sensor/test_arlo.py b/tests/components/sensor/test_arlo.py new file mode 100644 index 00000000000..d31490ab2af --- /dev/null +++ b/tests/components/sensor/test_arlo.py @@ -0,0 +1,240 @@ +"""The tests for the Netgear Arlo sensors.""" +from collections import namedtuple +from unittest.mock import patch, MagicMock +import pytest +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, ATTR_ATTRIBUTION) +from homeassistant.components.sensor import arlo +from homeassistant.components.arlo import DATA_ARLO + + +def _get_named_tuple(input_dict): + return namedtuple('Struct', input_dict.keys())(*input_dict.values()) + + +def _get_sensor(name='Last', sensor_type='last_capture', data=None): + if data is None: + data = {} + return arlo.ArloSensor(name, data, sensor_type) + + +@pytest.fixture() +def default_sensor(): + """Create an ArloSensor with default values.""" + return _get_sensor() + + +@pytest.fixture() +def battery_sensor(): + """Create an ArloSensor with battery data.""" + data = _get_named_tuple({ + 'battery_level': 50 + }) + return _get_sensor('Battery Level', 'battery_level', data) + + +@pytest.fixture() +def temperature_sensor(): + """Create a temperature ArloSensor.""" + return _get_sensor('Temperature', 'temperature') + + +@pytest.fixture() +def humidity_sensor(): + """Create a humidity ArloSensor.""" + return _get_sensor('Humidity', 'humidity') + + +@pytest.fixture() +def cameras_sensor(): + """Create a total cameras ArloSensor.""" + data = _get_named_tuple({ + 'cameras': [0, 0] + }) + return _get_sensor('Arlo Cameras', 'total_cameras', data) + + +@pytest.fixture() +def captured_sensor(): + """Create a captured today ArloSensor.""" + data = _get_named_tuple({ + 'captured_today': [0, 0, 0, 0, 0] + }) + return _get_sensor('Captured Today', 'captured_today', data) + + +class PlatformSetupFixture(): + """Fixture for testing platform setup call to add_devices().""" + + def __init__(self): + """Instantiate the platform setup fixture.""" + self.sensors = None + self.update = False + + def add_devices(self, sensors, update): + """Mock method for adding devices.""" + self.sensors = sensors + self.update = update + + +@pytest.fixture() +def platform_setup(): + """Create an instance of the PlatformSetupFixture class.""" + return PlatformSetupFixture() + + +@pytest.fixture() +def sensor_with_hass_data(default_sensor, hass): + """Create a sensor with async_dispatcher_connected mocked.""" + hass.data = {} + default_sensor.hass = hass + return default_sensor + + +@pytest.fixture() +def mock_dispatch(): + """Mock the dispatcher connect method.""" + target = 'homeassistant.components.sensor.arlo.async_dispatcher_connect' + with patch(target, MagicMock()) as _mock: + yield _mock + + +def test_setup_with_no_data(platform_setup, hass): + """Test setup_platform with no data.""" + arlo.setup_platform(hass, None, platform_setup.add_devices) + assert platform_setup.sensors is None + assert not platform_setup.update + + +def test_setup_with_valid_data(platform_setup, hass): + """Test setup_platform with valid data.""" + config = { + 'monitored_conditions': [ + 'last_capture', + 'total_cameras', + 'captured_today', + 'battery_level', + 'signal_strength', + 'temperature', + 'humidity', + 'air_quality' + ] + } + + hass.data[DATA_ARLO] = _get_named_tuple({ + 'cameras': [_get_named_tuple({ + 'name': 'Camera', + 'model_id': 'ABC1000' + })], + 'base_stations': [_get_named_tuple({ + 'name': 'Base Station', + 'model_id': 'ABC1000' + })] + }) + + arlo.setup_platform(hass, config, platform_setup.add_devices) + assert len(platform_setup.sensors) == 8 + assert platform_setup.update + + +def test_sensor_name(default_sensor): + """Test the name property.""" + assert default_sensor.name == 'Last' + + +async def test_async_added_to_hass(sensor_with_hass_data, mock_dispatch): + """Test dispatcher called when added.""" + await sensor_with_hass_data.async_added_to_hass() + assert len(mock_dispatch.mock_calls) == 1 + kall = mock_dispatch.call_args + args, kwargs = kall + assert len(args) == 3 + assert args[0] == sensor_with_hass_data.hass + assert args[1] == 'arlo_update' + assert not kwargs + + +def test_sensor_state_default(default_sensor): + """Test the state property.""" + assert default_sensor.state is None + + +def test_sensor_icon_battery(battery_sensor): + """Test the battery icon.""" + assert battery_sensor.icon == 'mdi:battery-50' + + +def test_sensor_icon(temperature_sensor): + """Test the icon property.""" + assert temperature_sensor.icon == 'mdi:thermometer' + + +def test_unit_of_measure(default_sensor, battery_sensor): + """Test the unit_of_measurement property.""" + assert default_sensor.unit_of_measurement is None + assert battery_sensor.unit_of_measurement == '%' + + +def test_device_class(default_sensor, temperature_sensor, humidity_sensor): + """Test the device_class property.""" + assert default_sensor.device_class is None + assert temperature_sensor.device_class == DEVICE_CLASS_TEMPERATURE + assert humidity_sensor.device_class == DEVICE_CLASS_HUMIDITY + + +def test_update_total_cameras(cameras_sensor): + """Test update method for total_cameras sensor type.""" + cameras_sensor.update() + assert cameras_sensor.state == 2 + + +def test_update_captured_today(captured_sensor): + """Test update method for captured_today sensor type.""" + captured_sensor.update() + assert captured_sensor.state == 5 + + +def _test_attributes(sensor_type): + data = _get_named_tuple({ + 'model_id': 'TEST123' + }) + sensor = _get_sensor('test', sensor_type, data) + attrs = sensor.device_state_attributes + assert attrs.get(ATTR_ATTRIBUTION) == 'Data provided by arlo.netgear.com' + assert attrs.get('brand') == 'Netgear Arlo' + assert attrs.get('model') == 'TEST123' + + +def test_state_attributes(): + """Test attributes for camera sensor types.""" + _test_attributes('battery_level') + _test_attributes('signal_strength') + _test_attributes('temperature') + _test_attributes('humidity') + _test_attributes('air_quality') + + +def test_attributes_total_cameras(cameras_sensor): + """Test attributes for total cameras sensor type.""" + attrs = cameras_sensor.device_state_attributes + assert attrs.get(ATTR_ATTRIBUTION) == 'Data provided by arlo.netgear.com' + assert attrs.get('brand') == 'Netgear Arlo' + assert attrs.get('model') is None + + +def _test_update(sensor_type, key, value): + data = _get_named_tuple({ + key: value + }) + sensor = _get_sensor('test', sensor_type, data) + sensor.update() + assert sensor.state == value + + +def test_update(): + """Test update method for direct transcription sensor types.""" + _test_update('battery_level', 'battery_level', 100) + _test_update('signal_strength', 'signal_strength', 100) + _test_update('temperature', 'ambient_temperature', 21.4) + _test_update('humidity', 'ambient_humidity', 45.1) + _test_update('air_quality', 'ambient_air_quality', 14.2) diff --git a/tests/components/sensor/test_efergy.py b/tests/components/sensor/test_efergy.py index 83309329a11..9a79ab5b81c 100644 --- a/tests/components/sensor/test_efergy.py +++ b/tests/components/sensor/test_efergy.py @@ -14,21 +14,20 @@ ONE_SENSOR_CONFIG = { 'platform': 'efergy', 'app_token': token, 'utc_offset': '300', - 'monitored_variables': [{'type': 'amount', 'period': 'day'}, - {'type': 'instant_readings'}, - {'type': 'budget'}, - {'type': 'cost', 'period': 'day', 'currency': '$'}, - {'type': 'current_values'} - ] + 'monitored_variables': [ + {'type': 'amount', 'period': 'day'}, + {'type': 'instant_readings'}, + {'type': 'budget'}, + {'type': 'cost', 'period': 'day', 'currency': '$'}, + {'type': 'current_values'}, + ] } MULTI_SENSOR_CONFIG = { 'platform': 'efergy', 'app_token': multi_sensor_token, 'utc_offset': '300', - 'monitored_variables': [ - {'type': 'current_values'} - ] + 'monitored_variables': [{'type': 'current_values'}], } @@ -36,22 +35,23 @@ def mock_responses(mock): """Mock responses for Efergy.""" base_url = 'https://engage.efergy.com/mobile_proxy/' mock.get( - base_url + 'getInstant?token=' + token, + '{}getInstant?token={}'.format(base_url, token), text=load_fixture('efergy_instant.json')) mock.get( - base_url + 'getEnergy?token=' + token + '&offset=300&period=day', + '{}getEnergy?token={}&offset=300&period=day'.format(base_url, token), text=load_fixture('efergy_energy.json')) mock.get( - base_url + 'getBudget?token=' + token, + '{}getBudget?token={}'.format(base_url, token), text=load_fixture('efergy_budget.json')) mock.get( - base_url + 'getCost?token=' + token + '&offset=300&period=day', + '{}getCost?token={}&offset=300&period=day'.format(base_url, token), text=load_fixture('efergy_cost.json')) mock.get( - base_url + 'getCurrentValuesSummary?token=' + token, + '{}getCurrentValuesSummary?token={}'.format(base_url, token), text=load_fixture('efergy_current_values_single.json')) mock.get( - base_url + 'getCurrentValuesSummary?token=' + multi_sensor_token, + '{}getCurrentValuesSummary?token={}'.format( + base_url, multi_sensor_token), text=load_fixture('efergy_current_values_multi.json')) @@ -69,7 +69,7 @@ class TestEfergySensor(unittest.TestCase): self.DEVICES.append(device) def setUp(self): - """Initialize values for this testcase class.""" + """Initialize values for this test case class.""" self.hass = get_test_home_assistant() self.config = ONE_SENSOR_CONFIG @@ -82,27 +82,31 @@ class TestEfergySensor(unittest.TestCase): """Test for successfully setting up the Efergy platform.""" mock_responses(mock) assert setup_component(self.hass, 'sensor', { - 'sensor': ONE_SENSOR_CONFIG}) - self.assertEqual('38.21', - self.hass.states.get('sensor.energy_consumed').state) - self.assertEqual('1580', - self.hass.states.get('sensor.energy_usage').state) - self.assertEqual('ok', - self.hass.states.get('sensor.energy_budget').state) - self.assertEqual('5.27', - self.hass.states.get('sensor.energy_cost').state) - self.assertEqual('1628', - self.hass.states.get('sensor.efergy_728386').state) + 'sensor': ONE_SENSOR_CONFIG, + }) + + self.assertEqual( + '38.21', self.hass.states.get('sensor.energy_consumed').state) + self.assertEqual( + '1580', self.hass.states.get('sensor.energy_usage').state) + self.assertEqual( + 'ok', self.hass.states.get('sensor.energy_budget').state) + self.assertEqual( + '5.27', self.hass.states.get('sensor.energy_cost').state) + self.assertEqual( + '1628', self.hass.states.get('sensor.efergy_728386').state) @requests_mock.Mocker() def test_multi_sensor_readings(self, mock): """Test for multiple sensors in one household.""" mock_responses(mock) assert setup_component(self.hass, 'sensor', { - 'sensor': MULTI_SENSOR_CONFIG}) - self.assertEqual('218', - self.hass.states.get('sensor.efergy_728386').state) - self.assertEqual('1808', - self.hass.states.get('sensor.efergy_0').state) - self.assertEqual('312', - self.hass.states.get('sensor.efergy_728387').state) + 'sensor': MULTI_SENSOR_CONFIG, + }) + + self.assertEqual( + '218', self.hass.states.get('sensor.efergy_728386').state) + self.assertEqual( + '1808', self.hass.states.get('sensor.efergy_0').state) + self.assertEqual( + '312', self.hass.states.get('sensor.efergy_728387').state) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 8e79306fe13..cf2cc9c4205 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -4,7 +4,8 @@ import unittest from unittest.mock import patch from homeassistant.components.sensor.filter import ( - LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter) + LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter, + RangeFilter) import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component import homeassistant.core as ha @@ -131,6 +132,23 @@ class TestFilterSensor(unittest.TestCase): filtered = filt.filter_state(state) self.assertEqual(18.05, filtered.state) + def test_range(self): + """Test if range filter works.""" + lower = 10 + upper = 20 + filt = RangeFilter(entity=None, + lower_bound=lower, + upper_bound=upper) + for unf_state in self.values: + unf = float(unf_state.state) + filtered = filt.filter_state(unf_state) + if unf < lower: + self.assertEqual(lower, filtered.state) + elif unf > upper: + self.assertEqual(upper, filtered.state) + else: + self.assertEqual(unf, filtered.state) + def test_throttle(self): """Test if lowpass filter works.""" filt = ThrottleFilter(window_size=3, diff --git a/tests/components/sensor/test_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py index f9ec83cc8be..cc57c801430 100644 --- a/tests/components/sensor/test_geo_rss_events.py +++ b/tests/components/sensor/test_geo_rss_events.py @@ -1,7 +1,10 @@ """The test for the geo rss events sensor platform.""" import unittest from unittest import mock +import sys + import feedparser +import pytest from homeassistant.setup import setup_component from tests.common import load_fixture, get_test_home_assistant @@ -22,6 +25,9 @@ VALID_CONFIG_WITHOUT_CATEGORIES = { } +# Until https://github.com/kurtmckee/feedparser/pull/131 is released. +@pytest.mark.skipif(sys.version_info[:2] >= (3, 7), + reason='Package incompatible with Python 3.7') class TestGeoRssServiceUpdater(unittest.TestCase): """Test the GeoRss service updater.""" diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index a8b8a201217..774185c51c1 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -79,8 +79,7 @@ class TestDeviceSunLightTrigger(unittest.TestCase): self.assertTrue(light.is_on(self.hass)) - def test_lights_turn_off_when_everyone_leaves(self): \ - # pylint: disable=invalid-name + def test_lights_turn_off_when_everyone_leaves(self): """Test lights turn off when everyone leaves the house.""" light.turn_on(self.hass) @@ -97,8 +96,7 @@ class TestDeviceSunLightTrigger(unittest.TestCase): self.assertFalse(light.is_on(self.hass)) - def test_lights_turn_on_when_coming_home_after_sun_set(self): \ - # pylint: disable=invalid-name + def test_lights_turn_on_when_coming_home_after_sun_set(self): """Test lights turn on when coming home after sun set.""" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) with patch('homeassistant.util.dt.utcnow', return_value=test_time): diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 5d909492380..70f7152e07f 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -428,8 +428,7 @@ class TestComponentHistory(unittest.TestCase): history.CONF_ENTITIES: ['media_player.test']}}}) self.check_significant_states(zero, four, states, config) - def check_significant_states(self, zero, four, states, config): \ - # pylint: disable=no-self-use + def check_significant_states(self, zero, four, states, config): """Check if significant states are retrieved.""" filters = history.Filters() exclude = config[history.DOMAIN].get(history.CONF_EXCLUDE) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 6c71a263afa..a3a5273ed4e 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -542,8 +542,7 @@ class TestComponentLogbook(unittest.TestCase): def create_state_changed_event(self, event_time_fired, entity_id, state, attributes=None, last_changed=None, - last_updated=None): \ - # pylint: disable=no-self-use + last_updated=None): """Create state changed event.""" # Logbook only cares about state change events that # contain an old state but will not actually act on it. diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index d9238336768..baeda2c49a8 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -5,6 +5,7 @@ import logging from homeassistant.bootstrap import async_setup_component from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA import homeassistant.components.snips as snips +from homeassistant.helpers.intent import (ServiceIntentHandler, async_register) from tests.common import (async_fire_mqtt_message, async_mock_intent, async_mock_service) @@ -124,6 +125,49 @@ async def test_snips_intent(hass, mqtt_mock): assert intent.text_input == 'turn the lights green' +async def test_snips_service_intent(hass, mqtt_mock): + """Test ServiceIntentHandler via Snips.""" + hass.states.async_set('light.kitchen', 'off') + calls = async_mock_service(hass, 'light', 'turn_on') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + payload = """ + { + "input": "turn the light on", + "intent": { + "intentName": "Lights", + "probability": 0.85 + }, + "siteId": "default", + "slots": [ + { + "slotName": "name", + "value": { + "kind": "Custom", + "value": "kitchen" + } + } + ] + } + """ + + async_register(hass, ServiceIntentHandler( + "Lights", "light", 'turn_on', "Turned {} on")) + + async_fire_mqtt_message(hass, 'hermes/intent/Lights', + payload) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'light' + assert calls[0].service == 'turn_on' + assert calls[0].data['entity_id'] == 'light.kitchen' + assert 'probability' not in calls[0].data + assert 'site_id' not in calls[0].data + + async def test_snips_intent_with_duration(hass, mqtt_mock): """Test intent with Snips duration.""" result = await async_setup_component(hass, "snips", { diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 6ea90bcdb88..dc1688bae16 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -341,6 +341,33 @@ async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): assert auth_msg['type'] == wapi.TYPE_AUTH_OK +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 + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': hass_access_token.token + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID + + async def test_auth_active_with_password_not_allow(hass, aiohttp_client): """Test authenticating with a token.""" assert await async_setup_component(hass, 'websocket_api', { diff --git a/tests/conftest.py b/tests/conftest.py index 0a350b62fc1..28c47948666 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ if os.environ.get('UVLOOP') == '1': import uvloop asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG) logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) diff --git a/tests/fixtures/efergy_budget.json b/tests/fixtures/efergy_budget.json index 2b0a64fbae5..73fc9b549b6 100644 --- a/tests/fixtures/efergy_budget.json +++ b/tests/fixtures/efergy_budget.json @@ -1 +1,4 @@ -{"status":"ok", "monthly_budget":250.0000} \ No newline at end of file +{ + "status": "ok", + "monthly_budget": 250.0000 +} \ No newline at end of file diff --git a/tests/fixtures/efergy_cost.json b/tests/fixtures/efergy_cost.json index 8b2ccfff18a..41150a30e87 100644 --- a/tests/fixtures/efergy_cost.json +++ b/tests/fixtures/efergy_cost.json @@ -1 +1,5 @@ -{"sum":"5.27","duration":70320,"units":"GBP"} \ No newline at end of file +{ + "sum": "5.27", + "duration": 70320, + "units": "GBP" +} \ No newline at end of file diff --git a/tests/fixtures/efergy_energy.json b/tests/fixtures/efergy_energy.json index 4033ad074a6..f1c1ce248be 100644 --- a/tests/fixtures/efergy_energy.json +++ b/tests/fixtures/efergy_energy.json @@ -1 +1,5 @@ -{"sum":"38.21","duration":70320,"units":"kWh"} \ No newline at end of file +{ + "sum": "38.21", + "duration": 70320, + "units": "kWh" +} \ No newline at end of file diff --git a/tests/fixtures/pushbullet_devices.json b/tests/fixtures/pushbullet_devices.json old mode 100755 new mode 100644 diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index a8d37a249bc..707129ae531 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1,6 +1,18 @@ """Tests for the intent helpers.""" + +import unittest +import voluptuous as vol + from homeassistant.core import State -from homeassistant.helpers import intent +from homeassistant.helpers import (intent, config_validation as cv) + + +class MockIntentHandler(intent.IntentHandler): + """Provide a mock intent handler.""" + + def __init__(self, slot_schema): + """Initialize the mock handler.""" + self.slot_schema = slot_schema def test_async_match_state(): @@ -10,3 +22,25 @@ def test_async_match_state(): state = intent.async_match_state(None, 'kitch', [state1, state2]) assert state is state1 + + +class TestIntentHandler(unittest.TestCase): + """Test the Home Assistant event helpers.""" + + def test_async_validate_slots(self): + """Test async_validate_slots of IntentHandler.""" + handler1 = MockIntentHandler({ + vol.Required('name'): cv.string, + }) + + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {}) + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {'name': 1}) + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {'name': 'kitchen'}) + handler1.async_validate_slots({'name': {'value': 'kitchen'}}) + handler1.async_validate_slots({ + 'name': {'value': 'kitchen'}, + 'probability': {'value': '0.5'} + }) diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index e6aa7893f33..f6c027150dd 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -4,23 +4,28 @@ from unittest.mock import Mock, patch import pytest from homeassistant.scripts import auth as script_auth -from homeassistant.auth_providers import homeassistant as hass_auth +from homeassistant.auth.providers import homeassistant as hass_auth + +from tests.common import register_auth_provider @pytest.fixture -def data(hass): - """Create a loaded data class.""" - data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) - return data +def provider(hass): + """Home Assistant auth provider.""" + provider = hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant', + })) + hass.loop.run_until_complete(provider.async_initialize()) + return provider -async def test_list_user(data, capsys): +async def test_list_user(hass, provider, capsys): """Test we can list users.""" - data.add_user('test-user', 'test-pass') - data.add_user('second-user', 'second-pass') + data = provider.data + data.add_auth('test-user', 'test-pass') + data.add_auth('second-user', 'second-pass') - await script_auth.list_users(data, None) + await script_auth.list_users(hass, provider, None) captured = capsys.readouterr() @@ -33,46 +38,49 @@ async def test_list_user(data, capsys): ]) -async def test_add_user(data, capsys, hass_storage): +async def test_add_user(hass, provider, capsys, hass_storage): """Test we can add a user.""" + data = provider.data await script_auth.add_user( - data, Mock(username='paulus', password='test-pass')) + hass, provider, Mock(username='paulus', password='test-pass')) assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 captured = capsys.readouterr() - assert captured.out == 'User created\n' + assert captured.out == 'Auth created\n' assert len(data.users) == 1 data.validate_login('paulus', 'test-pass') -async def test_validate_login(data, capsys): +async def test_validate_login(hass, provider, capsys): """Test we can validate a user login.""" - data.add_user('test-user', 'test-pass') + data = provider.data + data.add_auth('test-user', 'test-pass') await script_auth.validate_login( - data, Mock(username='test-user', password='test-pass')) + hass, provider, Mock(username='test-user', password='test-pass')) captured = capsys.readouterr() assert captured.out == 'Auth valid\n' await script_auth.validate_login( - data, Mock(username='test-user', password='invalid-pass')) + hass, provider, Mock(username='test-user', password='invalid-pass')) captured = capsys.readouterr() assert captured.out == 'Auth invalid\n' await script_auth.validate_login( - data, Mock(username='invalid-user', password='test-pass')) + hass, provider, Mock(username='invalid-user', password='test-pass')) captured = capsys.readouterr() assert captured.out == 'Auth invalid\n' -async def test_change_password(data, capsys, hass_storage): +async def test_change_password(hass, provider, capsys, hass_storage): """Test we can change a password.""" - data.add_user('test-user', 'test-pass') + data = provider.data + data.add_auth('test-user', 'test-pass') await script_auth.change_password( - data, Mock(username='test-user', new_password='new-pass')) + hass, provider, Mock(username='test-user', new_password='new-pass')) assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 captured = capsys.readouterr() @@ -82,12 +90,14 @@ async def test_change_password(data, capsys, hass_storage): data.validate_login('test-user', 'test-pass') -async def test_change_password_invalid_user(data, capsys, hass_storage): +async def test_change_password_invalid_user(hass, provider, capsys, + hass_storage): """Test changing password of non-existing user.""" - data.add_user('test-user', 'test-pass') + data = provider.data + data.add_auth('test-user', 'test-pass') await script_auth.change_password( - data, Mock(username='invalid-user', new_password='new-pass')) + hass, provider, Mock(username='invalid-user', new_password='new-pass')) assert hass_auth.STORAGE_KEY not in hass_storage captured = capsys.readouterr() @@ -101,11 +111,11 @@ def test_parsing_args(loop): """Test we parse args correctly.""" called = False - async def mock_func(data, args2): + async def mock_func(hass, provider, args2): """Mock function to be called.""" nonlocal called called = True - assert data.hass.config.config_dir == '/somewhere/config' + assert provider.hass.config.config_dir == '/somewhere/config' assert args2 is args args = Mock(config='/somewhere/config', func=mock_func) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 33154090286..540f8d91da9 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -168,8 +168,7 @@ class TestCheckConfig(unittest.TestCase): '.../configuration.yaml', '.../secrets.yaml'] @patch('os.path.isfile', return_value=True) - def test_package_invalid(self, isfile_patch): \ - # pylint: disable=no-self-use,invalid-name + def test_package_invalid(self, isfile_patch): """Test a valid platform setup.""" files = { YAML_CONFIG_FILE: BASE_CONFIG + ( @@ -190,8 +189,7 @@ class TestCheckConfig(unittest.TestCase): assert res['secrets'] == {} assert len(res['yaml_files']) == 1 - def test_bootstrap_error(self): \ - # pylint: disable=no-self-use,invalid-name + def test_bootstrap_error(self): """Test a valid platform setup.""" files = { YAML_CONFIG_FILE: BASE_CONFIG + 'automation: !include no.yaml', diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index e329f835f84..4f258bc2b09 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -81,7 +81,8 @@ def test_from_config_dict_not_mount_deps_folder(loop): async def test_async_from_config_file_not_mount_deps_folder(loop): """Test that we not mount the deps folder inside async_from_config_file.""" - hass = Mock(async_add_job=Mock(side_effect=lambda *args: mock_coro())) + hass = Mock( + async_add_executor_job=Mock(side_effect=lambda *args: mock_coro())) with patch('homeassistant.bootstrap.is_virtual_env', return_value=False), \ patch('homeassistant.bootstrap.async_enable_logging', diff --git a/tests/test_config.py b/tests/test_config.py index 717a3f62ec9..435d3a00ec2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,7 @@ import unittest.mock as mock from collections import OrderedDict import pytest -from voluptuous import MultipleInvalid +from voluptuous import MultipleInvalid, Invalid from homeassistant.core import DOMAIN, HomeAssistantError, Config import homeassistant.config as config_util @@ -15,7 +15,8 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME, CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, - CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT) + CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, + CONF_AUTH_PROVIDERS) from homeassistant.util import location as location_util, dt as dt_util from homeassistant.util.yaml import SECRET_YAML from homeassistant.util.async_ import run_coroutine_threadsafe @@ -790,3 +791,42 @@ def test_merge_customize(hass): assert hass.data[config_util.DATA_CUSTOMIZE].get('b.b') == \ {'friendly_name': 'BB'} + + +async def test_auth_provider_config(hass): + """Test loading auth provider config onto hass object.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_PROVIDERS: [ + {'type': 'homeassistant'}, + {'type': 'legacy_api_password'}, + ] + } + if hasattr(hass, 'auth'): + del hass.auth + await config_util.async_process_ha_core_config(hass, core_config) + + assert len(hass.auth.auth_providers) == 2 + assert hass.auth.active is True + + +async def test_disallowed_auth_provider_config(hass): + """Test loading insecure example auth provider is disallowed.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_PROVIDERS: [ + {'type': 'insecure_example'}, + ] + } + with pytest.raises(Invalid): + await config_util.async_process_ha_core_config(hass, core_config) diff --git a/tests/test_core.py b/tests/test_core.py index 4abce180093..7633c820d2d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -67,6 +67,18 @@ def test_async_add_job_add_threaded_job_to_pool(mock_iscoro): assert len(hass.loop.run_in_executor.mock_calls) == 1 +@patch('asyncio.iscoroutine', return_value=True) +def test_async_create_task_schedule_coroutine(mock_iscoro): + """Test that we schedule coroutines and add jobs to the job pool.""" + hass = MagicMock() + job = MagicMock() + + ha.HomeAssistant.async_create_task(hass, job) + assert len(hass.loop.call_soon.mock_calls) == 0 + assert len(hass.loop.create_task.mock_calls) == 1 + assert len(hass.add_job.mock_calls) == 0 + + def test_async_run_job_calls_callback(): """Test that the callback annotation is respected.""" hass = MagicMock() diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index e67d5de50d1..0296b8c2fba 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -11,6 +11,8 @@ from yarl import URL from aiohttp.client_exceptions import ClientResponseError +retype = type(re.compile('')) + class AiohttpClientMocker: """Mock Aiohttp client requests.""" @@ -40,7 +42,7 @@ class AiohttpClientMocker: if content is None: content = b'' - if not isinstance(url, re._pattern_type): + if not isinstance(url, retype): url = URL(url) if params: url = url.with_query(params) @@ -146,7 +148,7 @@ class AiohttpClientMockResponse: return False # regular expression matching - if isinstance(self._url, re._pattern_type): + if isinstance(self._url, retype): return self._url.search(str(url)) is not None if (self._url.scheme != url.scheme or self._url.host != url.host or diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 734f4b548b9..d08915b348b 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -411,6 +411,22 @@ class TestSecrets(unittest.TestCase): assert mock_error.call_count == 1, \ "Expected an error about logger: value" + def test_secrets_are_not_dict(self): + """Did secrets handle non-dict file.""" + FILES[self._secret_path] = ( + '- http_pw: pwhttp\n' + ' comp1_un: un1\n' + ' comp1_pw: pw1\n') + yaml.clear_secret_cache() + with self.assertRaises(HomeAssistantError): + load_yaml(self._yaml_path, + 'http:\n' + ' api_password: !secret http_pw\n' + 'component:\n' + ' username: !secret comp1_un\n' + ' password: !secret comp1_pw\n' + '') + def test_representing_yaml_loaded_data(): """Test we can represent YAML loaded data.""" diff --git a/tox.ini b/tox.ini index 8b034346475..fb36ac6511a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, lint, pylint, typing +envlist = py35, py36, py37, py38, lint, pylint, typing skip_missing_interpreters = True [testenv] @@ -25,7 +25,7 @@ deps = -r{toxinidir}/requirements_test.txt -c{toxinidir}/homeassistant/package_constraints.txt commands = - pylint homeassistant + pylint {posargs} homeassistant [testenv:lint] basepython = {env:PYTHON3_PATH:python3} @@ -42,4 +42,4 @@ whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - /bin/bash -c 'mypy --ignore-missing-imports --follow-imports=silent homeassistant/*.py' + /bin/bash -c 'mypy homeassistant/*.py homeassistant/util/' diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index 0cb49fde54e..15504ea57af 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -46,7 +46,7 @@ apt-get install -y --no-install-recommends ${PACKAGES[@]} ${PACKAGES_DEV[@]} # This is a list of scripts that install additional dependencies. If you only # need to install a package from the official debian repository, just add it # to the list above. Only create a script if you need compiling, manually -# downloading or a 3th party repository. +# downloading or a 3rd party repository. if [ "$INSTALL_TELLSTICK" == "yes" ]; then virtualization/Docker/scripts/tellstick fi