diff --git a/.coveragerc b/.coveragerc index a100e2c0a4958..73a79c2d87bf7 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 diff --git a/.travis.yml b/.travis.yml index b089d3f89be32..5b3c43ec8c8ba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,9 @@ matrix: env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 - # - python: "3.6-dev" - # env: TOXENV=py36 + - python: "3.7" + env: TOXENV=py37 + dist: xenial # allow_failures: # - python: "3.5" # env: TOXENV=typing diff --git a/homeassistant/auth.py b/homeassistant/auth.py index e6760cd9096bc..c84f5e83ef0aa 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -186,16 +186,6 @@ class Credentials: 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: @@ -321,10 +311,7 @@ async def async_get_or_create_user(self, credentials): if not credentials.is_new: for user in await self._store.async_get_users(): for creds in user.credentials: - if (creds.auth_provider_type == - credentials.auth_provider_type - and creds.auth_provider_id == - credentials.auth_provider_id): + if creds.id == credentials.id: return user raise ValueError('Unable to find the user.') @@ -356,20 +343,20 @@ 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=None): + 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 is not None: + 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 is None: + 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) + return await self._store.async_create_refresh_token(user, client_id) async def async_get_refresh_token(self, token): """Get refresh token by token.""" @@ -396,26 +383,6 @@ def async_get_access_token(self, token): 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] @@ -456,7 +423,6 @@ 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 async_get_users(self): @@ -515,9 +481,8 @@ async def async_remove_user(self, user): self._users.pop(user.id) await self.async_save() - async def async_create_refresh_token(self, user, client=None): + async def async_create_refresh_token(self, user, client_id=None): """Create a new token for a user.""" - client_id = client.id if client is not None else None refresh_token = RefreshToken(user=user, client_id=client_id) user.refresh_tokens[refresh_token.token] = refresh_token await self.async_save() @@ -535,38 +500,6 @@ async def async_get_refresh_token(self, 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() @@ -578,7 +511,6 @@ async def async_load(self): if data is None: self._users = {} - self._clients = {} return users = { @@ -618,12 +550,7 @@ async def async_load(self): ) 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.""" @@ -676,19 +603,8 @@ async def async_save(self): 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, diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f81d2ef1037cd..84a72945a7e26 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_alarm_service_handler(service): 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 0000000000000..893fa76c44b4b --- /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 ff2d4adf30dc3..9b7da71a29319 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -270,11 +270,14 @@ def serialize_properties(self): """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 @@ def get_property(self, name): 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/auth/__init__.py b/homeassistant/components/auth/__init__.py index 511999c52abaf..3e236876d6a7a 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -102,6 +102,7 @@ "token_type": "Bearer" } """ +from datetime import timedelta import logging import uuid @@ -114,8 +115,10 @@ FlowManagerIndexView, FlowManagerResourceView) 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'] @@ -143,8 +146,7 @@ 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, @@ -164,16 +166,16 @@ async def get(self, request): """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 +193,20 @@ def __init__(self, flow_mgr, store_credentials): 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 +218,7 @@ async def post(self, request, client, flow_id, data): 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) @@ -228,24 +234,31 @@ 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', + }, status_code=400) + grant_type = data.get('grant_type') if grant_type == 'authorization_code': - return await self._async_handle_auth_code(hass, client, 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, data) + return await self._async_handle_refresh_token( + hass, client_id, data) return self.json({ 'error': 'unsupported_grant_type', }, status_code=400) - async def _async_handle_auth_code(self, hass, client, data): + async def _async_handle_auth_code(self, hass, client_id, data): """Handle authorization code request.""" code = data.get('code') @@ -254,7 +267,7 @@ async def _async_handle_auth_code(self, hass, client, data): 'error': 'invalid_request', }, status_code=400) - credentials = self._retrieve_credentials(client.id, code) + credentials = self._retrieve_credentials(client_id, code) if credentials is None: return self.json({ @@ -263,7 +276,7 @@ async def _async_handle_auth_code(self, hass, client, data): user = await hass.auth.async_get_or_create_user(credentials) refresh_token = await hass.auth.async_create_refresh_token(user, - client) + client_id) access_token = hass.auth.async_create_access_token(refresh_token) return self.json({ @@ -274,7 +287,7 @@ async def _async_handle_auth_code(self, hass, client, data): int(refresh_token.access_token_expiration.total_seconds()), }) - async def _async_handle_refresh_token(self, hass, client, data): + async def _async_handle_refresh_token(self, hass, client_id, data): """Handle authorization code request.""" token = data.get('refresh_token') @@ -285,7 +298,7 @@ async def _async_handle_refresh_token(self, hass, client, data): refresh_token = await hass.auth.async_get_refresh_token(token) - if refresh_token is None or refresh_token.client_id != client.id: + if refresh_token is None or refresh_token.client_id != client_id: return self.json({ 'error': 'invalid_grant', }, status_code=400) @@ -338,12 +351,26 @@ 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 diff --git a/homeassistant/components/auth/client.py b/homeassistant/components/auth/client.py deleted file mode 100644 index 122c303218822..0000000000000 --- 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 0000000000000..ef7f8a9b2927f --- /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/ring.py b/homeassistant/components/binary_sensor/ring.py index e84009301ab75..4f2ea408e7f46 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -23,7 +23,7 @@ _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/google.py b/homeassistant/components/calendar/google.py index 87893125e6f74..279fb1e2694ce 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -4,7 +4,6 @@ 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/push.py b/homeassistant/components/camera/push.py index fc4b18e26e40d..def5c53dd3f06 100644 --- a/homeassistant/components/camera/push.py +++ b/homeassistant/components/camera/push.py @@ -29,6 +29,8 @@ 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, @@ -41,11 +43,14 @@ 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(cameras, + hass.http.register_view(CameraPushReceiver(hass, config[CONF_IMAGE_FIELD])) async_add_devices(cameras) @@ -57,19 +62,18 @@ class CameraPushReceiver(HomeAssistantView): url = "/api/camera_push/{entity_id}" name = 'api:camera_push:camera_entity' - def __init__(self, cameras, image_field): + def __init__(self, hass, image_field): """Initialize CameraPushReceiver with camera entity.""" - self._cameras = cameras + self._cameras = hass.data[PUSH_CAMERA_DATA] self._image = image_field async def post(self, request, entity_id): """Accept the POST from Camera.""" - try: - (_camera,) = [camera for camera in self._cameras - if camera.entity_id == entity_id] - except ValueError: - _LOGGER.error("Unknown push camera %s", entity_id) - return self.json_message('Unknown Push 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: @@ -101,6 +105,10 @@ def __init__(self, name, buffer_size, 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.""" diff --git a/homeassistant/components/cloudflare.py b/homeassistant/components/cloudflare.py new file mode 100644 index 0000000000000..ae400ca638569 --- /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/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index 92ef78f60f3b0..61eee99e72160 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -16,7 +16,7 @@ _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 5cb7e283c9972..bea02143d72a6 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -19,7 +19,7 @@ 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__) diff --git a/homeassistant/components/device_tracker/cisco_ios.py b/homeassistant/components/device_tracker/cisco_ios.py index c13f622c5bf10..1afea2c1607f7 100644 --- a/homeassistant/components/device_tracker/cisco_ios.py +++ b/homeassistant/components/device_tracker/cisco_ios.py @@ -16,7 +16,7 @@ _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 e9a7efeb64aab..dfc66a412c39f 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -66,7 +66,6 @@ def __init__(self, config): 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/unifi_direct.py b/homeassistant/components/device_tracker/unifi_direct.py index c3c4a48bb826f..228443fe22ba6 100644 --- a/homeassistant/components/device_tracker/unifi_direct.py +++ b/homeassistant/components/device_tracker/unifi_direct.py @@ -16,7 +16,7 @@ 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/dialogflow.py b/homeassistant/components/dialogflow.py index 7a0918aab25e0..28b3a05e403cb 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/enocean.py b/homeassistant/components/enocean.py index 879f6a6189980..75e456f62bde4 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -75,6 +75,7 @@ def callback(self, temp): _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 @@ def callback(self, temp): 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 @@ def callback(self, temp): 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 e86e7348d58fb..69d4905228ad2 100644 --- a/homeassistant/components/eufy.py +++ b/homeassistant/components/eufy.py @@ -49,7 +49,6 @@ 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/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 0b9c8edd4117b..0fa9f90805d0d 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180704.0'] +REQUIREMENTS = ['home-assistant-frontend==20180710.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -50,7 +50,7 @@ '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 @@ -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): @@ -391,6 +382,8 @@ async def get(self, request, extra=None): # 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 +392,9 @@ async def get(self, request, extra=None): 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') diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index 203b1a94b7f9e..fdbc338207202 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -25,6 +25,7 @@ REQUIREMENTS = [ 'google-api-python-client==1.6.4', + 'httplib2==0.10.3', 'oauth2client==4.0.0', ] diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 34fdcb2c035e8..237a6d219f0c3 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -26,6 +26,12 @@ '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 discovery_dispatch(service, discovery_info): 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 1428bbd3e563f..12de686d232d3 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ 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__) diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index c40e577ae4a57..54b05c464b546 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -6,6 +6,7 @@ DOMAIN = 'homematicip_cloud' COMPONENTS = [ + 'alarm_control_panel', 'binary_sensor', 'climate', 'light', diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index a232d9295a4d7..2cc62dce38ed7 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -27,7 +27,8 @@ async def auth_middleware(request, handler): 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/image_processing/facebox.py b/homeassistant/components/image_processing/facebox.py index f556b62e93542..c863f8045138e 100644 --- a/homeassistant/components/image_processing/facebox.py +++ b/homeassistant/components/image_processing/facebox.py @@ -10,20 +10,26 @@ 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 @@ 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 @@ def __init__(self, ip, port, camera_entity, name=None): 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 @@ def device_state_attributes(self): """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 1f1fa347dc9b5..0689c34c1a3ee 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/eufy.py b/homeassistant/components/light/eufy.py index 6f0a8816eea17..2e7370cb336f1 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/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py index 5984fb0365792..5c513113f90fb 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -17,7 +17,7 @@ _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' @@ -29,13 +29,13 @@ async def async_setup_platform(hass, config, async_add_devices, async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the HomematicIP lights from a config entry.""" - from homematicip.device import ( - BrandSwitchMeasuring) + from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring) 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)) if devices: @@ -67,13 +67,15 @@ class HomematicipLightMeasuring(HomematicipLight): """MomematicIP measuring light device.""" @property - def current_power_w(self): - """Return the current power usage in W.""" - return self._device.currentPowerConsumption - - @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 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 diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 71d3f9d95d717..19aff97491e9b 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -46,7 +46,7 @@ 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 @@ def max_mireds(self): @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 @@ def hs_color(self): 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/xiaomi_aqara.py b/homeassistant/components/light/xiaomi_aqara.py index 37ae60e3494db..75c85a4bfcfb7 100644 --- a/homeassistant/components/light/xiaomi_aqara.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -31,7 +31,7 @@ def __init__(self, device, name, xiaomi_hub): """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 @@ def parse_data(self, data, raw_data): 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 @@ def parse_data(self, data, raw_data): @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/media_extractor.py b/homeassistant/components/media_extractor.py index 85895fdd7516e..21accdf84b3a2 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ 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 ff0e4d907b11e..2b2b9eb5c2819 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -12,19 +12,19 @@ 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_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 @@ def __init__(self, receiver): 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 @@ def update(self): 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 @@ def source_list(self): """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 @@ def media_episode(self): """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 @@ def select_source(self, source): """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/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 4fe4da5a94270..6b161f86ab055 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -88,6 +88,8 @@ def async_update(self): 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 a47db7f633c4a..90638cd9dfce3 100644 --- a/homeassistant/components/media_player/pandora.py +++ b/homeassistant/components/media_player/pandora.py @@ -22,7 +22,7 @@ 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 be0c0527f1bc9..06f054a03f723 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -20,7 +20,7 @@ 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/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index c14a33dce0173..b9fe294146315 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -5,12 +5,13 @@ https://home-assistant.io/components/sensor.efergy/ """ import logging -import voluptuous as vol -from requests import RequestException, get +import requests +import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_CURRENCY +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,6 @@ 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 @@ def __init__(self, sensor_type, app_token, utc_offset, period, """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 @@ def __init__(self, sensor_type, app_token, utc_offset, period, 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 @@ def update(self): """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/netatmo.py b/homeassistant/components/sensor/netatmo.py index 191e587feafd1..bdc2c5990d925 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -5,7 +5,8 @@ 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 @@ 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 @@ 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,8 @@ '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], + 'lastupdated': ['Last Updated', 's', 'mdi:timer', None], } MODULE_SCHEMA = vol.Schema({ @@ -76,11 +77,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: @@ -285,6 +286,8 @@ def update(self): self._state = "High" elif data['wifi_status'] <= 55: self._state = "Full" + elif self.type == 'lastupdated': + self._state = int(time() - data['When']) class NetAtmoData(object): @@ -296,20 +299,57 @@ def __init__(self, auth, station): 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/switch/enocean.py b/homeassistant/components/switch/enocean.py index abe197485d404..986744aeec11b 100644 --- a/homeassistant/components/switch/enocean.py +++ b/homeassistant/components/switch/enocean.py @@ -18,10 +18,12 @@ 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 @@ def __init__(self, dev_id, devname): self._light = None self._on_state = False self._on_state2 = False + self.channel = channel self.stype = "switch" @property @@ -61,7 +65,7 @@ def turn_on(self, **kwargs): 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 @@ def turn_off(self, **kwargs): 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 891525d397915..7320ea8d5571d 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/tuya.py b/homeassistant/components/switch/tuya.py new file mode 100644 index 0000000000000..4f69e76f954b2 --- /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/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index 46c1a24caa0e8..d59331984b712 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -38,7 +38,8 @@ '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 0000000000000..7263871e249a0 --- /dev/null +++ b/homeassistant/components/tuya.py @@ -0,0 +1,160 @@ +""" +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.1'] + +_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 = { + '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 name(self): + """Return Tuya device name.""" + return self.tuya.name() + + @property + def icon(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 1b7d568523160..880b3604a86a4 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -57,7 +57,7 @@ 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/loader.py b/homeassistant/loader.py index 9e5efffdccbab..153d00f92fce5 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -87,7 +87,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 32374b90135b8..66b17cf9bd99d 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/scripts/keyring.py b/homeassistant/scripts/keyring.py index 0ca60894f9b98..ecb31bdef8616 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==13.1.0', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==13.2.0', 'keyrings.alt==3.1'] def run(args): diff --git a/requirements_all.txt b/requirements_all.txt index c72e56821d6f1..0690539bdee03 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 @@ -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 @@ -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,7 +415,7 @@ hole==0.3.0 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180704.0 +home-assistant-frontend==20180710.0 # homeassistant.components.homekit_controller # homekit==0.6 @@ -423,6 +423,7 @@ home-assistant-frontend==20180704.0 # homeassistant.components.homematicip_cloud homematicip==0.9.6 +# homeassistant.components.google # homeassistant.components.remember_the_milk httplib2==0.10.3 @@ -470,7 +471,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==13.1.0 +keyring==13.2.0 # homeassistant.scripts.keyring keyrings.alt==3.1 @@ -630,7 +631,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 @@ -757,6 +758,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 @@ -843,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 @@ -1340,6 +1344,9 @@ total_connect_client==0.18 # homeassistant.components.switch.transmission transmissionrpc==0.11 +# homeassistant.components.tuya +tuyapy==0.1.1 + # homeassistant.components.twilio twilio==5.7.0 @@ -1435,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 d6e92d5b8ffe3..c8d3be81468e0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,5 +13,5 @@ pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.0 -pytest==3.6.2 +pytest==3.6.3 requests_mock==1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aabbdc44bea15..1daaa106e9954 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -14,7 +14,7 @@ pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.0 -pytest==3.6.2 +pytest==3.6.3 requests_mock==1.5 @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180704.0 +home-assistant-frontend==20180710.0 # homeassistant.components.homematicip_cloud homematicip==0.9.6 @@ -113,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 diff --git a/setup.py b/setup.py index 928d894c9d1a9..bbf10dd309dc4 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ '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/common.py b/tests/common.py index ccb8f49ea9792..98a3b0a6074e2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -31,6 +31,8 @@ _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): @@ -330,8 +332,6 @@ def add_to_auth_manager(self, auth_mgr): 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 = {} diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index 21719c12569b3..ce94d1ecbfada 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 @@ '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 65ad22efae2c1..0000000000000 --- 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 0000000000000..7bd720ddf7003 --- /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 7cff04327b85c..c5c46d55e3997 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -1,22 +1,32 @@ """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.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): """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 +34,10 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): # Exchange code for tokens resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, 'grant_type': 'authorization_code', 'code': code - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 tokens = await resp.json() @@ -35,9 +46,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 +64,25 @@ 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 diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py index 853c002ba46f3..28a924bb43a67 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): @@ -25,17 +27,19 @@ 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={ + '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() @@ -43,9 +47,10 @@ async def async_get_code(hass, aiohttp_client): # Exchange code for tokens resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, 'grant_type': 'authorization_code', 'code': code - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 tokens = await resp.json() @@ -57,17 +62,19 @@ async def async_get_code(hass, aiohttp_client): # 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() diff --git a/tests/components/auth/test_init_login_flow.py b/tests/components/auth/test_init_login_flow.py index ad39fba399737..50bd03d6cedbc 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/conftest.py b/tests/components/conftest.py index 00e3ee88d1660..843866cbfbd6d 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,7 +3,7 @@ from homeassistant.setup import async_setup_component -from tests.common import MockUser +from tests.common import MockUser, CLIENT_ID @pytest.fixture @@ -28,11 +28,6 @@ async def create_client(hass): 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)) + hass.auth.async_create_refresh_token(user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 5344773fde659..476bed368d796 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -65,8 +65,10 @@ async def test_hap_setup_works(aioclient_mock): assert await hap.async_setup() is True assert hap.home is home - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 5 + 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') @@ -104,10 +106,10 @@ async def test_hap_reset_unloads_entry_if_setup(): 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) == 5 + 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) == 5 + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 6 diff --git a/tests/components/image_processing/test_facebox.py b/tests/components/image_processing/test_facebox.py index 9449ebf5f71de..86811f94db3fd 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 @@ 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 @@ 'height': 75, 'left': 63, 'top': 262, - 'width': 74}, - }] + 'width': 74}}] MATCHED_FACES = {'John Lennon': 58.12} @@ -58,16 +62,42 @@ } +@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 @@ def mock_face_event(event): 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/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 9b4c0c69ac63f..1c37c9049f318 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 @@ 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/sensor/test_efergy.py b/tests/components/sensor/test_efergy.py index 83309329a11d9..9a79ab5b81c47 100644 --- a/tests/components/sensor/test_efergy.py +++ b/tests/components/sensor/test_efergy.py @@ -14,21 +14,20 @@ '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 @@ def add_devices(self, devices, mock): 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 @@ def test_single_sensor_readings(self, mock): """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_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py index f9ec83cc8be0e..cc57c80143005 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 @@ } +# 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/fixtures/efergy_budget.json b/tests/fixtures/efergy_budget.json index 2b0a64fbae551..73fc9b549b6ed 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 8b2ccfff18a81..41150a30e87dc 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 4033ad074a622..f1c1ce248beb4 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/test_auth.py b/tests/test_auth.py index 8096a08167923..a53c5aaec99b8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,7 +6,8 @@ from homeassistant import auth, data_entry_flow 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 @@ -93,6 +94,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.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', @@ -181,10 +197,7 @@ async def test_saving_loading(hass, hass_storage): }) 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) + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) manager.async_create_access_token(refresh_token) @@ -195,10 +208,6 @@ async def test_saving_loading(hass, hass_storage): 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.""" @@ -225,11 +234,10 @@ def test_access_token_expired(): 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().add_to_auth_manager(manager) - refresh_token = await manager.async_create_refresh_token(user, client) + 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 @@ -242,19 +250,6 @@ async def test_cannot_retrieve_expired_access_token(hass): 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.""" - 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 - - async def test_generating_system_user(hass): """Test that we can add a system user.""" manager = await auth.auth_manager_from_config(hass, []) @@ -274,10 +269,9 @@ async def test_refresh_token_requires_client_for_user(hass): with pytest.raises(ValueError): await manager.async_create_refresh_token(user) - client = await manager.async_get_or_create_client('Test client') - token = await manager.async_create_refresh_token(user, client) + token = await manager.async_create_refresh_token(user, CLIENT_ID) assert token is not None - assert token.client_id == client.id + assert token.client_id == CLIENT_ID async def test_refresh_token_not_requires_client_for_system_user(hass): @@ -285,10 +279,9 @@ async def test_refresh_token_not_requires_client_for_system_user(hass): manager = await auth.auth_manager_from_config(hass, []) user = await manager.async_create_system_user('Hass.io') assert user.system_generated is True - client = await manager.async_get_or_create_client('Test client') with pytest.raises(ValueError): - await manager.async_create_refresh_token(user, client) + await manager.async_create_refresh_token(user, CLIENT_ID) token = await manager.async_create_refresh_token(user) assert token is not None diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index e67d5de50d1bc..0296b8c2fbab5 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -11,6 +11,8 @@ from aiohttp.client_exceptions import ClientResponseError +retype = type(re.compile('')) + class AiohttpClientMocker: """Mock Aiohttp client requests.""" @@ -40,7 +42,7 @@ def request(self, method, url, *, 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 @@ def match_request(self, method, url, params=None): 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/tox.ini b/tox.ini index ca82c83d0fcb8..578a431febf9c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, lint, pylint, typing +envlist = py35, py36, py37, lint, pylint, typing skip_missing_interpreters = True [testenv]